From 5c9d2bbed9c28bbc62fb9f9c3d5494d9929e9204 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:21 +0800 Subject: [PATCH 01/28] fix(cikgo): skip the destroy push when the course is gone --- .../course/lesson_plan/item/cikgo_push_concern.rb | 6 +++++- .../course/lesson_plan/lesson_plan_item_spec.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb b/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb index 85fcc9d7910..f010f4733c4 100644 --- a/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb +++ b/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb @@ -53,8 +53,12 @@ def update_payload } end + # `course&.` because the destroy push runs in `after_destroy_commit`: when the item goes away as + # part of its whole course being destroyed, the callback fires after that transaction has + # committed, so reloading `course` yields nil and there is nothing left to push to. Everywhere + # else `belongs_to :course` guarantees it is present. def push(method) - return unless pushable?(actable) && course.component_enabled?(Course::StoriesComponent) + return unless pushable?(actable) && course&.component_enabled?(Course::StoriesComponent) Cikgo::ResourcesService.push_resources!(course, [{ method: method, id: id.to_s }.merge(send("#{method}_payload"))]) rescue StandardError => e diff --git a/spec/models/course/lesson_plan/lesson_plan_item_spec.rb b/spec/models/course/lesson_plan/lesson_plan_item_spec.rb index 53d2a96be9a..037ae0805a3 100644 --- a/spec/models/course/lesson_plan/lesson_plan_item_spec.rb +++ b/spec/models/course/lesson_plan/lesson_plan_item_spec.rb @@ -67,6 +67,21 @@ end end + describe 'callbacks from Course::LessonPlan::Item::CikgoPushConcern' do + # The push runs in `after_destroy_commit`, so it fires only once the whole destroy has + # committed — by which point the course row is gone and `item.course` reloads to nil. A bare + # item cannot reproduce it: `pushable?` short-circuits on a nil actable. It needs a real + # pushable actable, whose cascade reaches the item through the assessment's `acts_as` + # belongs_to — which is also why `destroyed_by_association` is nil and unusable as the guard. + it 'does not raise when the item is destroyed along with its course' do + course_to_destroy = create(:course) + create(:assessment, :published, course: course_to_destroy) + + expect { course_to_destroy.destroy }.not_to raise_error + expect(Course.exists?(course_to_destroy.id)).to be false + end + end + context 'when actable object is declared to have a todo' do describe 'callbacks from Course::LessonPlan::ItemTodoConcern' do let(:course) { create(:course) } From 62d4e4082d111b1ee3f493dc34db4a356641b784 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:21 +0800 Subject: [PATCH 02/28] fix(toast): let an updated toast render a React node --- .../lib/hooks/toast/__test__/toast.test.tsx | 23 +++++++++++++++++++ client/app/lib/hooks/toast/loadingToast.ts | 4 +++- client/app/lib/hooks/toast/toast.tsx | 4 +++- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 client/app/lib/hooks/toast/__test__/toast.test.tsx diff --git a/client/app/lib/hooks/toast/__test__/toast.test.tsx b/client/app/lib/hooks/toast/__test__/toast.test.tsx new file mode 100644 index 00000000000..047701704f8 --- /dev/null +++ b/client/app/lib/hooks/toast/__test__/toast.test.tsx @@ -0,0 +1,23 @@ +import { toast as toastify } from 'react-toastify'; +import { render, screen } from '@testing-library/react'; + +import toast from '../toast'; + +jest.mock('react-toastify', () => ({ toast: { update: jest.fn() } })); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('does not wrap ReactNode update messages in revoked Immer proxies', async () => { + toast.update('toast-id', { + render: Assessment duplicated., + type: 'success', + }); + + const renderedMessage = (toastify.update as jest.Mock).mock.calls[0][1] + .render; + + expect(() => render(renderedMessage)).not.toThrow(); + expect(await screen.findByText('Assessment duplicated.')).toBeVisible(); +}); diff --git a/client/app/lib/hooks/toast/loadingToast.ts b/client/app/lib/hooks/toast/loadingToast.ts index c614ef937f0..11acedef79e 100644 --- a/client/app/lib/hooks/toast/loadingToast.ts +++ b/client/app/lib/hooks/toast/loadingToast.ts @@ -1,8 +1,10 @@ +import { ReactNode } from 'react'; + import { DEFAULT_TOAST_TIMEOUT_MS } from 'lib/components/wrappers/ToastProvider'; import toast from './toast'; -type Updater = (message: string) => void; +type Updater = (message: ReactNode) => void; export interface LoadingToast { update: Updater; diff --git a/client/app/lib/hooks/toast/toast.tsx b/client/app/lib/hooks/toast/toast.tsx index bafb0c50af9..6252f0c89b8 100644 --- a/client/app/lib/hooks/toast/toast.tsx +++ b/client/app/lib/hooks/toast/toast.tsx @@ -69,8 +69,10 @@ const customize = ( ): O | undefined => { if (!options) return undefined; + const render = isUpdateOptions(options) ? options.render : undefined; + return produce(options, (draft) => { - if (isUpdateOptions(draft)) draft.render = formattedMessage(draft.render); + if (isUpdateOptions(draft)) draft.render = formattedMessage(render); draft.icon = getIconForToastType(draft.type ?? 'default'); }); From cd9835d2e28f9e8bc9db7d14357a2140385a90d5 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:21 +0800 Subject: [PATCH 03/28] fix(admin): keep the nav tab selected on nested admin routes --- .../admin/components/AdminNavigablePage.tsx | 8 +++- .../__test__/AdminNavigablePage.test.tsx | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 client/app/bundles/system/admin/components/__test__/AdminNavigablePage.test.tsx diff --git a/client/app/bundles/system/admin/components/AdminNavigablePage.tsx b/client/app/bundles/system/admin/components/AdminNavigablePage.tsx index c0649bbd583..3bcc202271d 100644 --- a/client/app/bundles/system/admin/components/AdminNavigablePage.tsx +++ b/client/app/bundles/system/admin/components/AdminNavigablePage.tsx @@ -17,13 +17,19 @@ interface AdminNavigablePageProps { const AdminNavigablePage = (props: AdminNavigablePageProps): JSX.Element => { const location = useLocation(); const navigate = useNavigate(); + const activePath = + props.paths.find( + (path) => + location.pathname === path.path || + location.pathname.startsWith(`${path.path}/`), + )?.path ?? false; return ( navigate(value)} - value={location.pathname} + value={activePath} > {props.paths.map((path) => ( { + const page = render( + + , + title: 'Marketplace Listings', + path: '/admin/marketplace_listings', + }, + { + icon: , + title: 'Get Help', + path: '/admin/get_help', + }, + ]} + /> + } + path="/admin" + > + Listing detail} + path="marketplace_listings/:listingId" + /> + + , + { at: ['/admin/marketplace_listings/1'] }, + ); + + expect(await page.findByText('Listing detail')).toBeInTheDocument(); + expect( + page.getByRole('tab', { name: 'Marketplace Listings' }), + ).toHaveAttribute('aria-selected', 'true'); +}); From 2548040ee1b3b5408b6491f76ad9b128c02c2f32 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:31 +0800 Subject: [PATCH 04/28] feat(marketplace): serve an immutable snapshot per published version A version IS its publication datetime, and the snapshot it serves is duplicated into a `preview` container course so a published version can never change under the courses that adopted it. Non-admins cannot edit container content. --- .../marketplace/listings_controller.rb | 25 +- .../marketplace/questions_controller.rb | 9 +- .../marketplace_listings_controller.rb | 2 +- .../assessment/marketplace/duplication_job.rb | 2 +- ...ssessment_marketplace_ability_component.rb | 72 +++- app/models/course.rb | 1 + .../course/assessment/marketplace/listing.rb | 110 +++++- .../assessment/marketplace/listing_version.rb | 73 ++++ .../marketplace/preview_container_service.rb | 58 +++ .../assessment/marketplace/publish_service.rb | 204 ++++++++++ .../marketplace/listings/index.json.jbuilder | 2 +- ...tplace_versioning_and_preview_container.rb | 187 +++++++++ db/schema.rb | 45 ++- .../assessments_marketplace_spec.rb | 2 +- .../marketplace/listings_controller_spec.rb | 5 +- .../marketplace/questions_controller_spec.rb | 14 +- .../marketplace_listings_controller_spec.rb | 6 +- ...assessment_marketplace_listing_versions.rb | 12 + .../course_assessment_marketplace_listings.rb | 19 +- .../marketplace/duplication_job_spec.rb | 7 +- .../assessment/marketplace/listing_spec.rb | 366 +++++++++++++++++- .../marketplace/listing_version_spec.rb | 234 +++++++++++ .../assessment_marketplace_ability_spec.rb | 69 +++- spec/models/course_spec.rb | 19 + .../preview_container_service_spec.rb | 87 +++++ .../marketplace/publish_service_spec.rb | 317 +++++++++++++++ 26 files changed, 1882 insertions(+), 65 deletions(-) create mode 100644 app/models/course/assessment/marketplace/listing_version.rb create mode 100644 app/services/course/assessment/marketplace/preview_container_service.rb create mode 100644 app/services/course/assessment/marketplace/publish_service.rb create mode 100644 db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb create mode 100644 spec/factories/course_assessment_marketplace_listing_versions.rb create mode 100644 spec/models/course/assessment/marketplace/listing_version_spec.rb create mode 100644 spec/services/course/assessment/marketplace/preview_container_service_spec.rb create mode 100644 spec/services/course/assessment/marketplace/publish_service_spec.rb diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index eec9f66166f..77e87b01773 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -7,10 +7,15 @@ def index # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on # the acting-as record. The source course is deliberately NOT preloaded: the MVP exposes no # attribution, so nothing in the view reaches for it. + # + # `where.not(authoring_assessment_id: nil)` guards an orphaned listing: the authoring copy is + # nullable now that a listing outlives deletion of its origin, and browse reads every field + # off it, so a nil would 500 the whole page rather than hide one row. @listings = Course::Assessment::Marketplace::Listing.published. - includes(assessment: :lesson_plan_item).to_a + where.not(authoring_assessment_id: nil). + includes(authoring_assessment: :lesson_plan_item).to_a @adoption_counts = adoption_counts(@listings.map(&:id)) - @question_counts = question_counts(@listings.map(&:assessment_id)) + @question_counts = question_counts(@listings.map(&:authoring_assessment_id)) @destination_tabs = destination_tabs end end @@ -26,11 +31,15 @@ def duplicate def show ActsAsTenant.without_tenant do - @listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).find_by(id: params[:id]) + @listing = Course::Assessment::Marketplace::Listing.published. + includes(:authoring_assessment).find_by(id: params[:id]) raise CanCan::AccessDenied unless @listing - @assessment = @listing.assessment - authorize!(:preview_in_marketplace, @assessment) + @assessment = @listing.authoring_assessment + # An orphaned listing has nothing left to preview — see `index`. + raise CanCan::AccessDenied unless @assessment + + authorize!(:preview_in_marketplace, @listing) @destination_tabs = destination_tabs render 'show' end @@ -67,11 +76,13 @@ def destination_tabs def authorized_listings listings = ActsAsTenant.without_tenant do - Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).includes(:assessment) + # Orphaned listings excluded for the reason `index` gives — there is no content to copy. + Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]). + where.not(authoring_assessment_id: nil).includes(:authoring_assessment) end raise CanCan::AccessDenied if listings.empty? - listings.each { |listing| authorize!(:duplicate_from_marketplace, listing.assessment) } + listings.each { |listing| authorize!(:duplicate_from_marketplace, listing) } authorize!(:duplicate_to, current_course) listings end diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb index 91f4f066993..ccbba11729b 100644 --- a/app/controllers/course/assessment/marketplace/questions_controller.rb +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -4,12 +4,15 @@ class Course::Assessment::Marketplace::QuestionsController < Course::Assessment: def show ActsAsTenant.without_tenant do - listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment). + listing = Course::Assessment::Marketplace::Listing.published.includes(:authoring_assessment). find_by(id: params[:listing_id]) raise CanCan::AccessDenied unless listing - @assessment = listing.assessment - authorize!(:preview_in_marketplace, @assessment) + @assessment = listing.authoring_assessment + # An orphaned listing has nothing left to preview — see ListingsController#index. + raise CanCan::AccessDenied unless @assessment + + authorize!(:preview_in_marketplace, listing) @question = @assessment.questions.includes(:actable).find(params[:id]) @question_assessment = @question.question_assessments.find_by!(assessment: @assessment) diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb index 50e4a5e200f..1cf3dd4b557 100644 --- a/app/controllers/course/assessment/marketplace_listings_controller.rb +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -3,7 +3,7 @@ class Course::Assessment::MarketplaceListingsController < Course::Assessment::Co before_action :authorize_publish_to_marketplace! def create - listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment) + listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(authoring_assessment: @assessment) now = Time.zone.now listing.published = true listing.first_published_at ||= now diff --git a/app/jobs/course/assessment/marketplace/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb index 0463ef6aca5..1fdf02cd63c 100644 --- a/app/jobs/course/assessment/marketplace/duplication_job.rb +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -26,7 +26,7 @@ def perform_tracked(listing_ids, destination_course, destination_tab_id, options private def duplicate_listing(listing, destination_course, current_user) - source = listing.assessment + source = listing.authoring_assessment Course::Duplication::ObjectDuplicationService.duplicate_objects( source.course, destination_course, source, current_user: current_user ) diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index 45dc1e7922b..70f83276b34 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -2,26 +2,41 @@ module Course::AssessmentMarketplaceAbilityComponent include AbilityHost::Component + # Question types whose create/edit/delete are frozen inside a `preview` course. Mirrors the set + # granted in Course::Assessment::AssessmentAbility#allow_manage_questions. + PREVIEW_FROZEN_QUESTION_TYPES = [ + Course::Assessment::Question::ForumPostResponse, + Course::Assessment::Question::MultipleResponse, + Course::Assessment::Question::TextResponse, + Course::Assessment::Question::Programming, + Course::Assessment::Question::RubricBasedResponse, + Course::Assessment::Question::Scribing, + Course::Assessment::Question::VoiceResponse + ].freeze + def define_permissions allow_admins_publish_to_marketplace if user&.administrator? # System admins keep marketplace access via `can :manage, :all` (Ability#initialize); do not # emit a `cannot` for them or it would revoke that. For everyone else, access is per-person. - if course && !user&.administrator? - if can_access_marketplace? - allow_managers_access_marketplace - else - # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, - # which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace` - # regardless of the allow-list. This component runs after that one in the `define_permissions` - # super chain, so a `cannot` here takes precedence. This line is load-bearing. - cannot :access_marketplace, Course, id: course.id - end - end + define_non_admin_course_permissions if course && !user&.administrator? super end private + def define_non_admin_course_permissions + if can_access_marketplace? + allow_managers_access_marketplace + else + # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, + # which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace` + # regardless of the allow-list. This component runs after that one in the `define_permissions` + # super chain, so a `cannot` here takes precedence. This line is load-bearing. + cannot :access_marketplace, Course, id: course.id + end + restrict_preview_course_content if course.preview? + end + # Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns # >=1 course anywhere, OR is an instructor/administrator in any instance) and passes the allow-list # may browse, whatever their role in the course they are viewing. @@ -34,7 +49,7 @@ def marketplace_baseline_capable? user&.course_manager_or_owner? || user&.instance_instructor_or_administrator? end - # Part of the TEMPORARY allow-list gate (see the retirement seam on `can_access_marketplace?`). + # Part of the temporary allow-list gate (see the retirement seam on `can_access_marketplace?`). # When the allow-list is retired this whole method is deleted; the block check goes with it. def marketplace_visible_to_user? return true if user&.administrator? @@ -43,18 +58,35 @@ def marketplace_visible_to_user? !Course::Assessment::Marketplace::AccessBlock.blocked?(user) end - def allow_admins_publish_to_marketplace can :publish_to_marketplace, Course::Assessment end + # In a `preview` sandbox course, freeze assessment content for everyone except system administrators + # (who hold `can :manage, :all`). Previewers are enrolled as `manager` — the lowest role that can + # attempt, grade and publish — which also carries `can :manage, Course::Assessment` and question + # management, so revoke exactly the destructive/content verbs and leave that loop intact. + # + # These `cannot`s only take precedence because this runs after Course::AssessmentsAbilityComponent + # and Course::CourseAbilityComponent in the `define_permissions` super chain: AbilityHost.components + # is ordered by file path, and `_` (0x5F) sorts before `s` (0x73). Do not rename or move this file. + def restrict_preview_course_content + assessments_in_course = { tab: { category: { course_id: course.id } } } + cannot [:update, :destroy], Course::Assessment, assessments_in_course + cannot :delete_all_submissions, Course::Assessment, assessments_in_course + cannot :delete_submission, Course::Assessment::Submission, assessment: assessments_in_course + PREVIEW_FROZEN_QUESTION_TYPES.each do |question_class| + cannot [:create, :update, :destroy], question_class + end + end + def allow_managers_access_marketplace can :access_marketplace, Course, id: course.id - can :duplicate_from_marketplace, Course::Assessment do |assessment| - assessment.marketplace_listing&.published? || false - end - can :preview_in_marketplace, Course::Assessment do |assessment| - assessment.marketplace_listing&.published? || false - end + # Subject is the listing, not the assessment (design V13). Resolving it from an assessment would + # go through `Course::Assessment has_one :marketplace_listing`, which keys on + # `authoring_assessment_id` — but the assessment these actions serve is the container snapshot, + # never the authoring copy. Keying on the listing also survives orphaning, where it is gone. + can :duplicate_from_marketplace, Course::Assessment::Marketplace::Listing, &:published? + can :preview_in_marketplace, Course::Assessment::Marketplace::Listing, &:published? end -end \ No newline at end of file +end diff --git a/app/models/course.rb b/app/models/course.rb index 18a719b8893..491d26e4bdc 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -25,6 +25,7 @@ class Course < ApplicationRecord # rubocop:disable Metrics/ClassLength validates :gamified, inclusion: { in: [true, false] } validates :published, inclusion: { in: [true, false] } validates :enrollable, inclusion: { in: [true, false] } + validates :preview, inclusion: { in: [true, false] } validates :time_zone, length: { maximum: 255 }, allow_nil: true validates :creator, presence: true validates :updater, presence: true diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb index 0722ac64925..f48fa692c15 100644 --- a/app/models/course/assessment/marketplace/listing.rb +++ b/app/models/course/assessment/marketplace/listing.rb @@ -1,11 +1,26 @@ # frozen_string_literal: true class Course::Assessment::Marketplace::Listing < ApplicationRecord - belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing + # The mutable authoring copy — the origin-course assessment. Nullable: the listing outlives + # deletion of its origin (design §4.3). What the marketplace serves is `current_version.assessment`. + belongs_to :authoring_assessment, class_name: 'Course::Assessment', + inverse_of: :marketplace_listing, optional: true belongs_to :publisher, class_name: 'User', inverse_of: false + belongs_to :current_version, class_name: 'Course::Assessment::Marketplace::ListingVersion', + inverse_of: false, optional: true + belongs_to :source_course, class_name: 'Course', inverse_of: false, optional: true + # The instance the source course belonged to. Recorded as an id rather than a denormalised name + # because instances outlive courses: it survives the deletion this provenance exists for, and it + # yields the origin's host as well as its name — a course id only resolves on its own host. + belongs_to :source_instance, class_name: 'Instance', inverse_of: false, optional: true + belongs_to :fallback_maintainer, class_name: 'User', inverse_of: false, optional: true has_many :adoptions, class_name: 'Course::Assessment::Marketplace::Adoption', inverse_of: :listing, dependent: :destroy + has_many :versions, class_name: 'Course::Assessment::Marketplace::ListingVersion', + inverse_of: :listing, dependent: :destroy - validates :assessment_id, uniqueness: true + # `allow_nil` is load-bearing: an orphaned listing has a null authoring assessment, and without + # this the second orphan would collide with the first. + validates :authoring_assessment_id, uniqueness: true, allow_nil: true validates :publisher, presence: true validates :creator, presence: true validates :updater, presence: true @@ -15,4 +30,95 @@ class Course::Assessment::Marketplace::Listing < ApplicationRecord def adoption_count adoptions.distinct.count(:destination_course_id) end + + # Every listing with the associations the system-admin management view reads. Tenant-free because + # snapshots live in the container course (instance 0) while listings span every instance. + # @return [Array] + def self.for_admin_index + ActsAsTenant.without_tenant do + # The authoring assessment's own course and instance are preloaded because the view links to it + # by absolute url: a cross-instance assessment path only resolves on its instance's host. + includes(:source_course, :source_instance, + { current_version: { assessment: :lesson_plan_item } }, + { authoring_assessment: { lesson_plan_item: { course: :instance } } }). + order(id: :desc).to_a + end + end + + # An orphaned listing lost its authoring copy (the origin assessment was deleted) but still + # serves its last snapshot. Deliberately separate from `admin_state`, which is a display concern. + # @return [Boolean] + def orphaned? + authoring_assessment_id.nil? + end + + # Restorable = orphaned AND still holding a snapshot to duplicate a fresh authoring copy from. + # A listing that is not orphaned already has one; one without a version has nothing to copy. + # @return [Boolean] + def restorable? + orphaned? && current_version_id.present? + end + + # An unlisted listing kept its authoring copy but was taken off the marketplace. Distinct from + # orphaned, which is about the authoring copy rather than visibility — and an orphaned listing goes + # on serving its snapshot, so neither state collapses into the other. + # @return [Boolean] + def unlisted? + !orphaned? && !published? + end + + # Permanent deletion is offered only for a listing already off the marketplace — orphaned or + # unlisted. Requiring the unlist first keeps the reversible step ahead of the irreversible one, + # and leaves an unlisted listing's source assessment untouched, so it can be published again. + # @return [Boolean] + def purgeable? + orphaned? || unlisted? + end + + # Whether the authoring copy lives in the marketplace's container course rather than in a course + # somebody owns — true for a listing rebuilt after orphaning, and for one authored in the + # container directly. It is the only thing on the record that says where the copy an admin would + # edit actually is: `RestoreAuthoringJob` leaves the provenance fields on the origin course. + # + # `without_tenant` is load-bearing, not defensive. `Course` is `acts_as_tenant :instance` and the + # container lives in the dedicated preview instance, so under every real admin request the tenant + # scope filters it out and `authoring_assessment.course` returns nil rather than raising, making this + # answer `false` for exactly the listings it identifies. Same reason `.for_admin_index` is tenant-free. + # @return [Boolean] + def marketplace_hosted? + ActsAsTenant.without_tenant { authoring_assessment&.course&.preview? } || false + end + + # Whether the original source assessment is gone — either there is no authoring copy at all (never + # rebuilt after orphaning, or the rebuild failed), or there is one but it now lives in the + # marketplace container while the listing was published elsewhere. `RestoreAuthoringJob` produces + # the second case: it duplicates into the container but leaves provenance on the origin course. + # + # `source_course&.preview?` keeps this false for a listing authored in the container directly, + # where the container legitimately is the source course and nothing was ever lost. + # `without_tenant` for the reason `marketplace_hosted?` gives. + # @return [Boolean] + def source_assessment_deleted? + return true if orphaned? + + ActsAsTenant.without_tenant { marketplace_hosted? && !source_course&.preview? } + end + + # Whether the original source course is gone. `source_course_id`'s FK is `on_delete: :nullify`, so + # the id disappears when the course is destroyed while the denormalised `source_course_name` + # survives. Requiring the name too tells a real deletion apart from a legacy row that never + # recorded provenance at all — both have a nil id, only the deleted one carries a name. + # @return [Boolean] + def source_course_deleted? + source_course_id.nil? && source_course_name.present? + end + + # Visibility only: whether the listing is on the marketplace. The two deletion facts + # (`source_assessment_deleted?`, `source_course_deleted?`) are deliberately separate predicates + # rather than states here — a listing whose authoring copy was rebuilt into the container is + # visible and has a deleted origin at the same time, which one enum value cannot carry. + # @return [String] one of 'unlisted', 'published' + def admin_state + published? ? 'published' : 'unlisted' + end end diff --git a/app/models/course/assessment/marketplace/listing_version.rb b/app/models/course/assessment/marketplace/listing_version.rb new file mode 100644 index 00000000000..ff7274cafdb --- /dev/null +++ b/app/models/course/assessment/marketplace/listing_version.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::ListingVersion < ApplicationRecord + belongs_to :listing, class_name: 'Course::Assessment::Marketplace::Listing', + inverse_of: :versions + belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: false + belongs_to :published_by, class_name: 'User', inverse_of: false + + validates :published_at, presence: true, uniqueness: { scope: :listing_id } + validates :assessment, presence: true + validates :published_by, presence: true + validates :creator, presence: true + validates :updater, presence: true + + scope :ordered, -> { order(published_at: :asc) } + + # Version identity for a set of container assessments. Publishing duplicates the title verbatim and + # every snapshot lands in the same tab of the one container course, so nothing on the assessment row + # says which listing it belongs to — it can only be read back from here. The listing join supplies + # provenance that survives origin-course deletion, which snapshot is served, and whether it is listed. + # + # Two kinds are labelled: a snapshot has a version row and yields its publication datetime; a + # restored working copy has none — it is the listing's `authoring_assessment` — and yields + # `published_at: nil`, which the client renders as an "Authoring" chip. Without that second lookup + # it would be the one assessment in the container with no chip at all. + # + # @param [Array] assessment_ids + # @return [Hash{Integer => Hash}] keyed by assessment id, each holding `:listing_id`, + # `:published_at`, `:source`, `:latest` and `:listed`; assessments that are neither a snapshot + # nor a working copy are absent from the hash. + def self.labels_for_assessments(assessment_ids) + return {} if assessment_ids.empty? + + snapshot_labels(assessment_ids).merge(working_copy_labels(assessment_ids)) + end + + # `:id` must be a symbol: both joined tables have an `id`, and Rails qualifies symbols to this + # model's own table while passing strings through verbatim — `'id'` would reach Postgres + # unqualified and be rejected as ambiguous. `listed` reads the listing's `published` column + # directly rather than `admin_state`, so this query is not coupled to what that method means. + # + # @param [Array] assessment_ids + # @return [Hash{Integer => Hash}] + def self.snapshot_labels(assessment_ids) + joins(:listing). + where(assessment_id: assessment_ids). + pluck(:assessment_id, :listing_id, :id, :published_at, + 'course_assessment_marketplace_listings.source_course_name', + 'course_assessment_marketplace_listings.current_version_id', + 'course_assessment_marketplace_listings.published'). + to_h do |(assessment_id, listing_id, version_id, published_at, source, current_version_id, listed)| + [assessment_id, listing_id: listing_id, published_at: published_at, source: source, + latest: version_id == current_version_id, listed: listed] + end + end + private_class_method :snapshot_labels + + # The working copy is not a version, so `latest` is unconditionally false — the listing's + # `current_version` always points at a snapshot, never at this. `listed` belongs to the listing, + # so it is reported here exactly as it is on that listing's snapshots. + # + # @param [Array] assessment_ids + # @return [Hash{Integer => Hash}] + def self.working_copy_labels(assessment_ids) + Course::Assessment::Marketplace::Listing. + where(authoring_assessment_id: assessment_ids). + pluck(:authoring_assessment_id, :id, :source_course_name, :published). + to_h do |assessment_id, listing_id, source, listed| + [assessment_id, listing_id: listing_id, published_at: nil, source: source, + latest: false, listed: listed] + end + end + private_class_method :working_copy_labels +end diff --git a/app/services/course/assessment/marketplace/preview_container_service.rb b/app/services/course/assessment/marketplace/preview_container_service.rb new file mode 100644 index 00000000000..2312331c1bd --- /dev/null +++ b/app/services/course/assessment/marketplace/preview_container_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# Provisions (idempotently) the single dedicated preview instance and the one content-frozen +# container course that backs the marketplace. Behaviour keys off `Course#preview`, never off a +# specific instance id. +# +# The container stores every published version snapshot (see PublishService), and those same rows are +# what previewers attempt hands-on — a snapshot is the preview copy, so a preview can never differ +# from what a duplicate gives you. The content-freeze in AssessmentMarketplaceAbilityComponent then +# doubles as both the previewer sandbox guard and the snapshots' immutability guarantee. +class Course::Assessment::Marketplace::PreviewContainerService + PREVIEW_INSTANCE_HOST = 'preview.coursemology.org' + PREVIEW_INSTANCE_NAME = 'Marketplace Preview' + PREVIEW_COURSE_TITLE = 'Marketplace Preview Sandbox' + + class << self + # @return [Instance] the dedicated non-default preview instance. + # + # `save!(validate: false)` because `Instance#host` gsubs `coursemology.org` for the environment's + # default host, and hostname validation reads through that overridden accessor rather than the raw + # column — so a `*.coursemology.org` host validated against a `localhost:PORT` dev/test default + # always fails on the injected colon. `db/seeds.rb` works around it the same way. + def preview_instance + Instance.find_by(host: PREVIEW_INSTANCE_HOST) || + Instance.new(host: PREVIEW_INSTANCE_HOST, name: PREVIEW_INSTANCE_NAME).tap do |instance| + instance.save!(validate: false) + end + end + + # @return [Course] the single `preview: true` container course in the preview instance. + def container_course + instance = preview_instance + ActsAsTenant.with_tenant(instance) do + Course.find_by(preview: true) || create_container_course(instance) + end + end + + private + + # `published/gamified/enrollable: false` keep the container out of every listing, level and + # self-enrolment path: it holds the marketplace's snapshots, so it must never surface as a + # course in its own right. Previewers are attached to it explicitly, one at a time. + def create_container_course(instance) + User.with_stamper(User.system) do + Course.create!( + instance: instance, + title: PREVIEW_COURSE_TITLE, + description: 'System container for marketplace version snapshots and hands-on previews.', + preview: true, + published: false, + gamified: false, + enrollable: false, + creator: User.system, + updater: User.system + ) + end + end + end +end diff --git a/app/services/course/assessment/marketplace/publish_service.rb b/app/services/course/assessment/marketplace/publish_service.rb new file mode 100644 index 00000000000..d8761257b04 --- /dev/null +++ b/app/services/course/assessment/marketplace/publish_service.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +# Publishes an assessment to the marketplace (copy-on-publish, design V2/§5.1): (re)activate the +# listing, capture provenance, snapshot the authoring assessment into the hidden container course, +# and point `current_version` at the snapshot. `.publish` cuts v1 on first publish only — re-listing +# an already-versioned listing does not cut a version; `.publish_new_version` is that explicit action. +class Course::Assessment::Marketplace::PublishService # rubocop:disable Metrics/ClassLength + # @param [Course::Assessment] assessment the source assessment being published + # @param [User] publisher the user triggering the publish + # @return [Course::Assessment::Marketplace::Listing] + def self.publish(assessment, publisher) + new(assessment, publisher).publish + end + + # Idempotent single-listing version cut, reused by the backfill. No-op (returns the + # existing version) if the listing already has one. + # @param [Course::Assessment::Marketplace::Listing] listing an already-published listing + # @param [User] publisher + # @return [Course::Assessment::Marketplace::ListingVersion] + def self.ensure_first_version!(listing, publisher) + # An orphaned listing has no authoring copy to snapshot from; there is nothing to cut and + # nothing to repair here (the fork-from-latest-snapshot path is a later slice). + return nil if listing.authoring_assessment.nil? + + new(listing.authoring_assessment, publisher).ensure_first_version!(listing) + end + + # Deliberate version cut (design §5.1). Snapshots whatever the authoring copy currently is into + # the container as version N+1 and advances `current_version`. Prior snapshots are retained — + # they are what Phase-3 comments and contributions will anchor to. + # + # There is deliberately no content-diff gating: `Course::Assessment#updated_at` does not track + # content changes, and walking the object graph misses edits below any fixed depth and misses + # deletions entirely (app/CLAUDE.md). The publisher decides when to cut. + # + # @param [Course::Assessment::Marketplace::Listing] listing + # @param [User] publisher + # @return [Course::Assessment::Marketplace::ListingVersion] + def self.publish_new_version(listing, publisher) + raise ArgumentError, 'cannot cut a version from an orphaned listing' if listing.authoring_assessment.nil? + + new(listing.authoring_assessment, publisher).cut_next_version!(listing) + end + + # One-time backfill: version every published, version-less listing and stamp + # `adopted_version = 1` on its version-less adoptions. Idempotent. + # @return [void] + def self.backfill_all! + ActsAsTenant.without_tenant do + # Keying idempotency on the absence of any version — not on a nil `current_version_id` — makes + # reruns safe; in production the two are equivalent, since a version is only ever created + # together with `current_version`. `where.not(authoring_assessment_id: nil)` skips orphans: + # without it the backfill raises partway through and leaves the rest unversioned. + Course::Assessment::Marketplace::Listing.published. + where.not(authoring_assessment_id: nil). + where.missing(:versions).find_each do |listing| + version = ensure_first_version!(listing, listing.publisher) + next if version.nil? + + listing.adoptions.where(adopted_version_at: nil). + update_all(adopted_version_at: version.published_at) + end + end + nil + end + + # One-time backfill for `source_instance`: read it off the surviving source course. Idempotent — + # only NULL rows are touched, so a rerun cannot overwrite a captured value. + # + # A listing that is already orphaned has no `source_course_id` to read and is left NULL on purpose; + # nothing else on the row identifies its origin instance (the snapshots live in the preview + # instance, and a publisher can belong to several instances). + # @return [void] + def self.backfill_source_instances! + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::Listing. + where(source_instance_id: nil).where.not(source_course_id: nil). + includes(:source_course).find_each do |listing| + # `update_columns` deliberately skips validations and callbacks, matching the sibling + # provenance backfill: this is a pure data fill and must neither stamp `updated_at` nor + # trip userstamp on rows whose creator context is long gone. + listing.update_columns(source_instance_id: listing.source_course.instance_id) + end + end + nil + end + + def initialize(assessment, publisher) + @assessment = assessment + @publisher = publisher + end + + # @return [Course::Assessment::Marketplace::Listing] + def publish + with_publish_context do + listing = activate_listing + cut_first_version!(listing) if listing.current_version_id.nil? + listing + end + end + + # @return [Course::Assessment::Marketplace::ListingVersion] + def ensure_first_version!(listing) + return listing.current_version if listing.current_version_id + + with_publish_context do + capture_provenance(listing) + listing.save! + cut_first_version!(listing) + end + listing.current_version + end + + # @param [Course::Assessment::Marketplace::Listing] listing + # @return [Course::Assessment::Marketplace::ListingVersion] + def cut_next_version!(listing) + with_publish_context do + snapshot = snapshot_into_container(@assessment) + # One instant, written to both rows. Two `Time.zone.now` calls would let the version row and + # the listing disagree by milliseconds — and different surfaces read different ones. + published_at = Time.zone.now + version = listing.versions.create!(published_at: published_at, assessment: snapshot, + published_by: @publisher, + creator: @publisher, updater: @publisher) + listing.update!(current_version: version, last_published_at: published_at) + version + end + end + + private + + # Runs the publish body without a tenant (the container lives in the dedicated preview + # instance, never the caller's; callers may be scoped to any instance) and with the stamper + # set so nested creator/updater resolve on the listing, version, and snapshot copy. + def with_publish_context(&block) + ActsAsTenant.without_tenant do + User.with_stamper(@publisher) do + Course::Assessment::Marketplace::Listing.transaction(&block) + end + end + end + + # @return [Course::Assessment::Marketplace::Listing] + def activate_listing + listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(authoring_assessment: @assessment) + now = Time.zone.now + listing.published = true + listing.first_published_at ||= now + listing.last_published_at = now + listing.publisher ||= @publisher + capture_provenance(listing) + listing.save! + listing + end + + # Denormalized so the identity survives origin-course deletion (design §3.2): the course row is what + # gets deleted, so its title is copied rather than read through `source_course`. + def capture_provenance(listing) + course = @assessment.course + listing.source_course ||= course + listing.source_instance ||= course.instance + listing.source_course_name ||= course.title + listing.fallback_maintainer ||= course.course_users.find_by(role: :owner)&.user + end + + # `published_at` is the listing's first-publication date rather than the moment of the cut: when v1 + # is cut, the listing's first publication is when its content became available. Baking it in here is + # what removed the read-time v1 special case on ListingVersion. + def cut_first_version!(listing) + snapshot = snapshot_into_container(listing.authoring_assessment) + version = listing.versions.create!(published_at: listing.first_published_at || Time.zone.now, + assessment: snapshot, published_by: @publisher, + creator: @publisher, updater: @publisher) + listing.update!(current_version: version) + version + end + + # The snapshot is simultaneously the row previewers attempt hands-on — see + # PreviewContainerService. Its immutability is enforced by the container's `preview` freeze, not + # by convention. `duplicate_objects` performs no ability checks, so the freeze cannot block the + # publish that populates the container. + # + # @return [Course::Assessment] the immutable snapshot living in the container course + def snapshot_into_container(assessment) + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( + assessment.course, container, assessment, current_user: @publisher + ) + reparent_into_container_tab(copy, container) + # A published snapshot is a standalone assessment, not a link-sibling of the origin. See + # Course::Assessment#detach_from_link_tree!. + copy.detach_from_link_tree! + copy + end + + def reparent_into_container_tab(copy, container) + tab = container.assessment_categories.first.tabs.first + return if copy.tab_id == tab.id + + copy.tab = tab + copy.folder.parent = tab.category.folder + copy.save! + end +end diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder index ead481dbe7a..5b67c876813 100644 --- a/app/views/course/assessment/marketplace/listings/index.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.canAccess true json.listings @listings do |listing| - assessment = listing.assessment + assessment = listing.authoring_assessment json.id listing.id json.assessmentId assessment.id json.title assessment.title diff --git a/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb b/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb new file mode 100644 index 00000000000..7ff490881d7 --- /dev/null +++ b/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true +# Marketplace versioning (design V2/V10/V17/§4.3–§5.1) in one migration: the marketplace stops +# serving live source content and serves an immutable snapshot held in the preview container course. +# +# Deliberately one migration, not a schema/data pair per slice. The data backfill calls application +# code (`PublishService.backfill_all!`), which always reflects this branch's final schema, so the +# rename and `courses.preview` must already have run. Split across separate timestamps, a from-scratch +# `db:migrate` died on `PG::UndefinedColumn` — only `db:schema:load` masked it. +# +# Fold a further schema change on this branch into the schema group, never after the backfills. The +# cost is that any DB which already ran this migration must be rebuilt from the template under a fresh +# tag; that is affordable only while this branch is unmerged. +class AddMarketplaceVersioningAndPreviewContainer < ActiveRecord::Migration[7.2] # rubocop:disable Metrics/ClassLength + def up + create_versions_table + add_listing_versioning_columns + add_source_instance_to_listings + add_adoption_vintage_column + repoint_listing_assessment_to_authoring + add_preview_flag_to_courses + + backfill_source_instances + backfill_first_versions + end + + def down + remove_column :courses, :preview + restore_listing_assessment_column + remove_adoption_vintage_column + remove_source_instance_from_listings + remove_listing_versioning_columns + drop_table :course_assessment_marketplace_listing_versions + end + + private + + def create_versions_table + create_table :course_assessment_marketplace_listing_versions do |t| + t.references :listing, null: false, + foreign_key: { to_table: :course_assessment_marketplace_listings, + name: 'fk_camlv_listing_id', + on_delete: :cascade }, + index: { name: 'fk__camlv_listing_id' } + # A version IS its publication datetime (2026-07-28 design §2). There is no ordinal: an + # integer would name a series the system cannot navigate — there is no rollback — and the + # stable internal referent is already this row's primary key. + t.datetime :published_at, null: false + t.references :assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_camlv_assessment_id' }, + index: { name: 'fk__camlv_assessment_id' } + t.references :published_by, null: false, + foreign_key: { to_table: :users, name: 'fk_camlv_published_by' }, + index: { name: 'fk__camlv_published_by' } + t.references :creator, null: false, + foreign_key: { to_table: :users, name: 'fk_camlv_creator_id' }, + index: { name: 'fk__camlv_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, name: 'fk_camlv_updater_id' }, + index: { name: 'fk__camlv_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_listing_versions, [:listing_id, :published_at], + unique: true, name: 'index_camlv_on_listing_id_and_published_at' + end + + def add_listing_versioning_columns + change_table :course_assessment_marketplace_listings, bulk: true do |t| + t.references :current_version, null: true, + foreign_key: { to_table: :course_assessment_marketplace_listing_versions, + name: 'fk_caml_current_version_id', + on_delete: :nullify }, + index: { name: 'fk__caml_current_version_id' } + t.references :source_course, null: true, + foreign_key: { to_table: :courses, + name: 'fk_caml_source_course_id', + on_delete: :nullify }, + index: { name: 'fk__caml_source_course_id' } + t.string :source_course_name + t.references :fallback_maintainer, null: true, + foreign_key: { to_table: :users, + name: 'fk_caml_fallback_maintainer_id' }, + index: { name: 'fk__caml_fallback_maintainer_id' } + end + end + + def remove_listing_versioning_columns + change_table :course_assessment_marketplace_listings, bulk: true do |t| + t.remove :current_version_id, :source_course_id, :source_course_name, :fallback_maintainer_id + end + end + + # The marketplace is cross-instance: a system admin sees listings whose source courses live in + # other instances, and `Course` is `acts_as_tenant :instance`, so a course id only resolves on its + # own instance's host. Recording the source instance is what lets the admin table both name the + # origin ("which CS1010?") and build links that work (`//host/courses/:id`). + # + # An id, not a denormalised name string like `source_course_name` above: those + # are strings precisely because their subject (the course) gets deleted, whereas instances are + # long-lived. An id therefore survives the case we care about while yielding both the display name + # and the host. + def add_source_instance_to_listings + add_reference :course_assessment_marketplace_listings, :source_instance, + null: true, + foreign_key: { to_table: :instances, + name: 'fk_caml_source_instance_id', + on_delete: :nullify }, + index: { name: 'fk__caml_source_instance_id' } + end + + def remove_source_instance_from_listings + remove_reference :course_assessment_marketplace_listings, :source_instance, + foreign_key: { to_table: :instances, name: 'fk_caml_source_instance_id' }, + index: { name: 'fk__caml_source_instance_id' } + end + + def add_adoption_vintage_column + # A datetime, not a version number: this is the content vintage the copy was made from, compared + # against the listing's current version's `published_at`. Stored as a value rather than an FK so + # a copy still knows how old its content is even if the version row is purged with its listing. + # + # There is no companion "dismissed" or "reminder mode" column: an adopter cannot silence the + # update notice, so being behind is the whole of the state. + add_column :course_assessment_marketplace_adoptions, :adopted_version_at, :datetime + end + + def remove_adoption_vintage_column + remove_column :course_assessment_marketplace_adoptions, :adopted_version_at + end + + # Design V10/§4.3. `assessment_id` no longer means "what the marketplace shows" — that is now + # `current_version.assessment` (the container snapshot). The column becomes the nullable authoring + # copy, and the FK flips cascade -> nullify so deleting the origin orphans the listing instead of + # destroying it along with its version chain and every adopter's adoption row. + def repoint_listing_assessment_to_authoring + remove_foreign_key :course_assessment_marketplace_listings, :course_assessments, + column: :assessment_id + remove_index :course_assessment_marketplace_listings, column: :assessment_id + rename_column :course_assessment_marketplace_listings, :assessment_id, :authoring_assessment_id + change_column_null :course_assessment_marketplace_listings, :authoring_assessment_id, true + + add_index :course_assessment_marketplace_listings, :authoring_assessment_id, + unique: true, where: 'authoring_assessment_id IS NOT NULL', + name: 'index_caml_on_authoring_assessment_id' + add_foreign_key :course_assessment_marketplace_listings, :course_assessments, + column: :authoring_assessment_id, + name: 'fk_caml_authoring_assessment_id', on_delete: :nullify + end + + def restore_listing_assessment_column + remove_foreign_key :course_assessment_marketplace_listings, :course_assessments, + column: :authoring_assessment_id + remove_index :course_assessment_marketplace_listings, name: 'index_caml_on_authoring_assessment_id' + + # Orphans have no authoring assessment to point back at, so the NOT NULL cannot be restored + # while they exist. This is why `down` is destructive and why this is not a `change`. + execute 'DELETE FROM course_assessment_marketplace_listings WHERE authoring_assessment_id IS NULL' + change_column_null :course_assessment_marketplace_listings, :authoring_assessment_id, false + rename_column :course_assessment_marketplace_listings, :authoring_assessment_id, :assessment_id + + add_index :course_assessment_marketplace_listings, :assessment_id, + unique: true, name: 'fk__course_assessment_marketplace_listings_assessment_id' + add_foreign_key :course_assessment_marketplace_listings, :course_assessments, + column: :assessment_id, + name: 'fk_course_assessment_marketplace_listings_assessment_id', + on_delete: :cascade + end + + def add_preview_flag_to_courses + add_column :courses, :preview, :boolean, default: false, null: false + end + + # Listings published before this migration read their instance off the surviving source course. Rows + # already orphaned (`source_course_id IS NULL`) have nothing to read and stay NULL, displayed as "—" + # forever: the snapshot lives in the preview instance rather than the origin and a publisher can + # belong to several instances, so neither identifies the origin. Idempotent — only NULL rows. + def backfill_source_instances + Course::Assessment::Marketplace::PublishService.backfill_source_instances! + end + + # Versions every existing published listing as v1 (snapshotting into the container via the publish + # service) and stamps adopted_version = 1 on its adoptions. Idempotent — reruns skip + # already-versioned listings. + def backfill_first_versions + Course::Assessment::Marketplace::PublishService.backfill_all! + end +end diff --git a/db/schema.rb b/db/schema.rb index 0d63cf08be9..2d9493bfa24 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_20_154800) do +ActiveRecord::Schema[7.2].define(version: 2026_07_28_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -287,6 +287,7 @@ t.bigint "updater_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "adopted_version_at" t.index ["creator_id"], name: "fk__cama_creator_id" t.index ["destination_course_id"], name: "fk__cama_destination_course_id" t.index ["duplicated_assessment_id"], name: "fk__cama_duplicated_assessment_id", unique: true @@ -311,8 +312,25 @@ t.index ["user_id"], name: "index_marketplace_allowlist_rules_one_per_user", unique: true, where: "(rule_type = 0)" end - create_table "course_assessment_marketplace_listings", force: :cascade do |t| + create_table "course_assessment_marketplace_listing_versions", force: :cascade do |t| + t.bigint "listing_id", null: false + t.datetime "published_at", null: false t.bigint "assessment_id", null: false + t.bigint "published_by_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assessment_id"], name: "fk__camlv_assessment_id" + t.index ["creator_id"], name: "fk__camlv_creator_id" + t.index ["listing_id", "published_at"], name: "index_camlv_on_listing_id_and_published_at", unique: true + t.index ["listing_id"], name: "fk__camlv_listing_id" + t.index ["published_by_id"], name: "fk__camlv_published_by" + t.index ["updater_id"], name: "fk__camlv_updater_id" + end + + create_table "course_assessment_marketplace_listings", force: :cascade do |t| + t.bigint "authoring_assessment_id" t.boolean "published", default: false, null: false t.datetime "first_published_at" t.datetime "last_published_at" @@ -321,10 +339,19 @@ t.bigint "updater_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["assessment_id"], name: "fk__course_assessment_marketplace_listings_assessment_id", unique: true + t.bigint "current_version_id" + t.bigint "source_course_id" + t.string "source_course_name" + t.bigint "fallback_maintainer_id" + t.bigint "source_instance_id" + t.index ["authoring_assessment_id"], name: "index_caml_on_authoring_assessment_id", unique: true, where: "(authoring_assessment_id IS NOT NULL)" t.index ["creator_id"], name: "fk__course_assessment_marketplace_listings_creator_id" + t.index ["current_version_id"], name: "fk__caml_current_version_id" + t.index ["fallback_maintainer_id"], name: "fk__caml_fallback_maintainer_id" t.index ["published"], name: "index_course_assessment_marketplace_listings_on_published" t.index ["publisher_id"], name: "fk__course_assessment_marketplace_listings_publisher_id" + t.index ["source_course_id"], name: "fk__caml_source_course_id" + t.index ["source_instance_id"], name: "fk__caml_source_instance_id" t.index ["updater_id"], name: "fk__course_assessment_marketplace_listings_updater_id" end @@ -1684,6 +1711,7 @@ t.text "user_suspension_message" t.boolean "is_suspended", default: false, null: false t.text "course_suspension_message" + t.boolean "preview", default: false, null: false t.index ["creator_id"], name: "fk__courses_creator_id" t.index ["instance_id"], name: "fk__courses_instance_id" t.index ["registration_key"], name: "index_courses_on_registration_key", unique: true @@ -2012,8 +2040,17 @@ add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" add_foreign_key "course_assessment_marketplace_allowlist_rules", "instances" add_foreign_key "course_assessment_marketplace_allowlist_rules", "users" - add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_listing_versions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_camlv_listing_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_listing_versions", "course_assessments", column: "assessment_id", name: "fk_camlv_assessment_id" + add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "creator_id", name: "fk_camlv_creator_id" + add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "published_by_id", name: "fk_camlv_published_by" + add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "updater_id", name: "fk_camlv_updater_id" + add_foreign_key "course_assessment_marketplace_listings", "course_assessment_marketplace_listing_versions", column: "current_version_id", name: "fk_caml_current_version_id", on_delete: :nullify + add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "authoring_assessment_id", name: "fk_caml_authoring_assessment_id", on_delete: :nullify + add_foreign_key "course_assessment_marketplace_listings", "courses", column: "source_course_id", name: "fk_caml_source_course_id", on_delete: :nullify + add_foreign_key "course_assessment_marketplace_listings", "instances", column: "source_instance_id", name: "fk_caml_source_instance_id", on_delete: :nullify add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" + add_foreign_key "course_assessment_marketplace_listings", "users", column: "fallback_maintainer_id", name: "fk_caml_fallback_maintainer_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "updater_id", name: "fk_course_assessment_marketplace_listings_updater_id" add_foreign_key "course_assessment_plagiarism_checks", "course_assessments", column: "assessment_id", name: "fk_course_assessment_plagiarism_checks_assessment_id" diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index 8c0b966a9ad..98bc1d0c3d7 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -23,7 +23,7 @@ end it 'reports isPublishedToMarketplace true once a published listing exists' do - create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) get :show, as: :json, params: { course_id: course, id: assessment } expect(JSON.parse(response.body)).to include('isPublishedToMarketplace' => true) end diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index bdf1398de0e..ceb0944f923 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -55,7 +55,8 @@ it 'reports the actual question count for a listing (not the 0 fallback)' do assessment_with_questions = create(:assessment, :with_mcq_question, question_count: 3, course: course) - listing = create(:course_assessment_marketplace_listing, published: true, assessment: assessment_with_questions) + listing = create(:course_assessment_marketplace_listing, published: true, + authoring_assessment: assessment_with_questions) get :index, params: { course_id: course, format: :json } row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } expect(row['questionCount']).to eq(3) @@ -228,7 +229,7 @@ let!(:listing) do assessment = create(:assessment, course: create(:course)) create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) - create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) end it 'renders the assessment config read-only' do diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index de6d5949df3..e8d4134ec7c 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -23,7 +23,7 @@ # NOTE: the factory has no :published trait — `published { true }` is a default attribute # (spec/factories/course_assessment_marketplace_listings.rb). Do NOT pass `:published`. ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: source_assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: source_assessment) end end let(:question) { source_assessment.questions.first } @@ -82,7 +82,7 @@ ) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -102,7 +102,7 @@ create(:course_assessment_question_text_response, :exact_match_solution, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -121,7 +121,7 @@ create(:course_assessment_question_rubric_based_response, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -140,7 +140,7 @@ create(:course_assessment_question_forum_post_response, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -157,7 +157,7 @@ create(:course_assessment_question_voice_response, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -175,7 +175,7 @@ create(:course_assessment_question_scribing, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end diff --git a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb index ed42190bd36..ddb8dfb63cd 100644 --- a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb @@ -24,7 +24,7 @@ context 'when the assessment was previously published then removed (re-publish)' do let!(:listing) do - create(:course_assessment_marketplace_listing, assessment: assessment, published: false, + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: false, first_published_at: 3.days.ago, last_published_at: 3.days.ago) end @@ -46,7 +46,9 @@ end describe 'DELETE #destroy' do - let!(:listing) { create(:course_assessment_marketplace_listing, assessment: assessment, published: true) } + let!(:listing) do + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) + end it 'soft-removes: keeps the row, sets published false' do delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } diff --git a/spec/factories/course_assessment_marketplace_listing_versions.rb b/spec/factories/course_assessment_marketplace_listing_versions.rb new file mode 100644 index 00000000000..0e48566bce9 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_listing_versions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_listing_version, + class: Course::Assessment::Marketplace::ListingVersion do + listing { association :course_assessment_marketplace_listing } + assessment + published_by { listing.publisher } + # Distinct per row: `published_at` is unique per listing, and a factory that stamped the same + # instant twice would collide the moment a spec cut two versions of one listing. + sequence(:published_at) { |n| n.minutes.ago } + end +end diff --git a/spec/factories/course_assessment_marketplace_listings.rb b/spec/factories/course_assessment_marketplace_listings.rb index 6ea0a819ffe..39eba4c6250 100644 --- a/spec/factories/course_assessment_marketplace_listings.rb +++ b/spec/factories/course_assessment_marketplace_listings.rb @@ -5,10 +5,25 @@ transient do course { nil } end - assessment { association :assessment, course: course || create(:course) } - publisher { assessment.course.creator } + authoring_assessment { association :assessment, course: course || create(:course) } + publisher { authoring_assessment.course.creator } published { true } first_published_at { Time.zone.now } last_published_at { Time.zone.now } + + # Mirrors the post-Slice-2 shape: a listing whose served content is a snapshot distinct from the + # authoring copy. The stand-in snapshot is created in the origin's own course rather than the + # shared preview container, so unrelated specs neither pay for container (and preview-instance) + # creation nor grow it. The real container path is covered by `publish_service_spec.rb`. + trait :versioned do + after(:create) do |listing, _evaluator| + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: listing.first_published_at || Time.zone.now, + published_by: listing.publisher) + listing.update!(current_version: version) + end + end end end diff --git a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb index 1dfb4505036..be2e2e5c607 100644 --- a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb +++ b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb @@ -6,7 +6,9 @@ with_tenant(:instance) do let(:source_course) { create(:course) } let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } - let(:listing) { create(:course_assessment_marketplace_listing, assessment: source_assessment, published: true) } + let(:listing) do + create(:course_assessment_marketplace_listing, authoring_assessment: source_assessment, published: true) + end let(:destination_course) { create(:course) } let(:destination_tab) { destination_course.assessment_categories.first.tabs.first } let(:user) { create(:administrator) } @@ -48,7 +50,8 @@ def run it 'duplicates every listing when given several ids' do other = create(:course_assessment_marketplace_listing, - assessment: create(:assessment, :with_mcq_question, course: source_course), published: true) + authoring_assessment: create(:assessment, :with_mcq_question, course: source_course), + published: true) expect do described_class.perform_now([listing.id, other.id], destination_course, destination_tab.id, current_user: user) end.to change { destination_course.assessments.count }.by(2). diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb index 7eeb9a26f3c..46aff30f530 100644 --- a/spec/models/course/assessment/marketplace/listing_spec.rb +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -4,8 +4,9 @@ RSpec.describe Course::Assessment::Marketplace::Listing, type: :model do let!(:instance) { Instance.default } with_tenant(:instance) do - it { is_expected.to belong_to(:assessment).class_name('Course::Assessment') } + it { is_expected.to belong_to(:authoring_assessment).class_name('Course::Assessment').optional } it { is_expected.to belong_to(:publisher).class_name('User') } + it { is_expected.to belong_to(:source_instance).class_name('Instance').optional } it do is_expected.to have_many(:adoptions). class_name('Course::Assessment::Marketplace::Adoption').dependent(:destroy) @@ -16,9 +17,10 @@ it { is_expected.to validate_presence_of(:publisher) } - it 'validates uniqueness of assessment_id' do + it 'validates uniqueness of authoring_assessment_id' do existing = create(:course_assessment_marketplace_listing) - dup = build(:course_assessment_marketplace_listing, assessment: existing.assessment) + dup = build(:course_assessment_marketplace_listing, + authoring_assessment: existing.authoring_assessment) expect(dup).not_to be_valid end end @@ -43,5 +45,363 @@ expect(subject.adoption_count).to eq(2) end end + + describe 'versioning associations (additive; all nullable)' do + let(:listing) { create(:course_assessment_marketplace_listing) } + + it 'is valid without any versioning fields set' do + expect(listing.current_version).to be_nil + expect(listing.source_course).to be_nil + expect(listing.source_instance).to be_nil + expect(listing.fallback_maintainer).to be_nil + expect(listing).to be_valid + end + + # Nullify rather than cascade: losing the instance a listing was published from must not take + # the listing, its version chain and every adopter's adoption row with it. + it 'keeps the listing but nullifies the reference when the source instance is deleted' do + origin_instance = create(:instance) + listing.update!(source_instance: origin_instance) + + expect { origin_instance.destroy! }. + not_to(change { described_class.where(id: listing.id).count }) + expect(listing.reload.source_instance_id).to be_nil + end + + it 'has many ordered versions and can point at a current version' do + earlier = 2.days.ago.change(usec: 0) + later = 1.day.ago.change(usec: 0) + v1 = create(:course_assessment_marketplace_listing_version, listing: listing, published_at: earlier) + v2 = create(:course_assessment_marketplace_listing_version, listing: listing, published_at: later) + listing.update!(current_version: v2) + expect(listing.versions.ordered).to eq([v1, v2]) + expect(listing.current_version).to eq(v2) + end + + it 'destroys its versions when destroyed' do + create(:course_assessment_marketplace_listing_version, listing: listing) + expect { listing.destroy }. + to change { Course::Assessment::Marketplace::ListingVersion.count }.by(-1) + end + + it 'does not destroy versions belonging to another listing' do + other_listing = create(:course_assessment_marketplace_listing) + other_version = create(:course_assessment_marketplace_listing_version, + listing: other_listing) + create(:course_assessment_marketplace_listing_version, listing: listing) + + expect { listing.destroy }. + to change { Course::Assessment::Marketplace::ListingVersion.count }.by(-1) + expect(other_version.reload).to be_persisted + end + + it 'optionally references a source course, fallback maintainer, and provenance' do + course = create(:course) + maintainer = create(:user) + listing.update!(source_course: course, source_course_name: 'Intro to AI', + fallback_maintainer: maintainer) + expect(listing.source_course).to eq(course) + expect(listing.fallback_maintainer).to eq(maintainer) + expect(listing.source_course_name).to eq('Intro to AI') + end + end + + describe 'the :versioned factory trait' do + it 'cuts a v1 whose assessment is distinct from the authoring copy' do + listing = create(:course_assessment_marketplace_listing, :versioned) + + expect(listing.current_version).to be_present + expect(listing.current_version.published_at).to be_within(1.second).of(listing.first_published_at) + expect(listing.current_version.assessment).not_to eq(listing.authoring_assessment) + end + + it 'leaves the listing unversioned when the trait is not applied' do + expect(create(:course_assessment_marketplace_listing).current_version).to be_nil + end + end + + describe 'maintenance predicates' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } + + def orphan!(target = listing) + target.authoring_assessment.destroy! + target.reload + end + + describe '#orphaned?' do + it 'is false while the authoring assessment exists' do + expect(listing).not_to be_orphaned + end + + it 'is true once the authoring assessment is deleted' do + expect(orphan!).to be_orphaned + end + end + + describe '#restorable?' do + it 'is true for an orphaned listing that still has a version' do + expect(orphan!).to be_restorable + end + + it 'is false while the listing still has an authoring copy' do + expect(listing).not_to be_restorable + end + + it 'is false for an orphaned listing with no version to restore from' do + unversioned = create(:course_assessment_marketplace_listing) + expect(orphan!(unversioned)).not_to be_restorable + end + end + + describe '#unlisted?' do + it 'is true once a listing with an authoring copy is taken off the marketplace' do + listing.update!(published: false) + expect(listing).to be_unlisted + end + + it 'is false while the listing is published' do + expect(listing).not_to be_unlisted + end + + # Orphaning is about the authoring copy, unlisting about marketplace visibility, and an + # orphaned listing keeps serving its snapshot — so the two states are reported separately + # rather than one collapsing into the other. + it 'is false for an orphaned listing even though it has no authoring copy' do + expect(orphan!).not_to be_unlisted + end + end + + # Purge is offered for a listing that is NOT on the marketplace — orphaned or unlisted — + # regardless of adoption history: a deliberate admin deletion of an adopted listing must be + # allowed to proceed. + describe '#purgeable?' do + it 'is true for an orphaned listing with no adoptions' do + expect(orphan!).to be_purgeable + end + + it 'is true for an unlisted listing with no adoptions' do + listing.update!(published: false) + expect(listing).to be_purgeable + end + + # Unlisting is the reversible step and has to be taken first; it is also what makes the + # deletion recoverable, since the source assessment survives and can be published again. + it 'is false for a published listing' do + expect(listing).not_to be_purgeable + end + + it 'is true for an orphaned listing that has been adopted' do + create(:course_assessment_marketplace_adoption, listing: listing) + expect(orphan!).to be_purgeable + end + + it 'is true for an unlisted listing that has been adopted' do + create(:course_assessment_marketplace_adoption, listing: listing) + listing.update!(published: false) + + expect(listing).to be_purgeable + end + end + end + + describe '#admin_state' do + let(:origin_course) { create(:course) } + # Eager: the deleted-course example destroys `origin_course`, and a lazily built listing would + # then try to create its authoring assessment inside a course that no longer exists. + let!(:listing) do + create(:course_assessment_marketplace_listing, course: origin_course, source_course: origin_course) + end + + it 'is published while the listing is listed and still has its authoring copy' do + expect(listing.admin_state).to eq('published') + end + + it 'is unlisted once the listing is unpublished' do + listing.update!(published: false) + expect(listing.admin_state).to eq('unlisted') + end + + # Visibility did not change: `admin_state` no longer tracks the authoring copy at all, so a + # deleted origin assessment leaves a published listing published. The deletion fact now lives + # on `#source_assessment_deleted?` instead (see below). + it 'stays published when the authoring assessment is deleted' do + listing.authoring_assessment.destroy! + expect(listing.reload.admin_state).to eq('published') + end + + # Same reasoning for a deleted origin course: visibility is untouched. + it 'stays published when the origin course is deleted' do + origin_course.destroy! + expect(listing.reload.admin_state).to eq('published') + end + + it 'reports unlisted, not a deletion fact, when an unpublished listing loses its copy' do + listing.update!(published: false) + listing.authoring_assessment.destroy! + expect(listing.reload.admin_state).to eq('unlisted') + end + end + + # These two predicates carry the deletion facts that used to live inside `admin_state` as + # 'orphaned_assessment_deleted' / 'orphaned_course_deleted'. Split out because a listing whose + # authoring copy was rebuilt into the marketplace container is visible (published) AND has a + # deleted origin at the same time — one enum value cannot report both. + describe '#source_assessment_deleted?' do + let(:origin_course) { create(:course) } + let!(:listing) do + create(:course_assessment_marketplace_listing, :versioned, course: origin_course, + source_course: origin_course) + end + + it 'is false for a normal published listing with an intact authoring copy' do + expect(listing).not_to be_source_assessment_deleted + end + + it 'is false for an unlisted listing with an intact authoring copy' do + listing.update!(published: false) + expect(listing).not_to be_source_assessment_deleted + end + + it 'is true once the authoring assessment is destroyed (no rebuild yet)' do + listing.authoring_assessment.destroy! + expect(listing.reload).to be_source_assessment_deleted + end + + # `RestoreAuthoringJob` always duplicates into the container and leaves `source_course` + # pointing at the ORIGIN, so this is the rebuilt case: published and marketplace-hosted, but + # the original is still gone. + it 'is true once the authoring copy is rebuilt into the marketplace container' do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + rebuilt = ActsAsTenant.with_tenant(container.instance) { create(:assessment, course: container) } + listing.update!(authoring_assessment: rebuilt) + + expect(listing).to be_source_assessment_deleted + expect(listing).to be_marketplace_hosted + end + + # The case the predicate exists to get right: authored in the container DIRECTLY, so the + # container legitimately IS the source course and nothing was ever lost. + it 'is false for a listing authored in the container directly' do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + container_assessment = ActsAsTenant.with_tenant(container.instance) do + create(:assessment, course: container) + end + direct = create(:course_assessment_marketplace_listing, authoring_assessment: container_assessment, + source_course: container, + publisher: create(:user)) + + expect(direct).not_to be_source_assessment_deleted + expect(direct).to be_marketplace_hosted + end + end + + describe '#source_course_deleted?' do + let(:origin_course) { create(:course) } + let!(:listing) do + create(:course_assessment_marketplace_listing, course: origin_course, source_course: origin_course, + source_course_name: origin_course.title) + end + + it 'is false while the origin course exists' do + expect(listing).not_to be_source_course_deleted + end + + it 'is false when only the authoring assessment is deleted, since the origin course survives' do + listing.authoring_assessment.destroy! + expect(listing.reload).not_to be_source_course_deleted + end + + # The FK nullifies `source_course_id` on course deletion; `source_course_name` is denormalised + # and survives, which is what tells a real deletion apart from a legacy row lacking provenance. + it 'is true once the origin course itself is destroyed' do + origin_course.destroy! + listing.reload + + expect(listing.source_course_id).to be_nil + expect(listing).to be_source_course_deleted + expect(listing).to be_source_assessment_deleted + end + + it 'is false for a legacy listing that never recorded a source course at all' do + legacy = create(:course_assessment_marketplace_listing) + expect(legacy).not_to be_source_course_deleted + end + end + + # Orthogonal to `admin_state`: this reports WHERE the authoring copy lives, while `admin_state` + # reports marketplace visibility. A rebuilt listing can go on to be unlisted, so neither answer + # can be read off the other. + describe '#marketplace_hosted?' do + it 'is false while the authoring copy lives in an ordinary course' do + listing = create(:course_assessment_marketplace_listing) + expect(listing).not_to be_marketplace_hosted + end + + # Keyed off `Course#preview`, never off a specific instance id — the same rule + # PreviewContainerService documents, so a container in any instance reports correctly. + it 'is true once the authoring copy lives in a preview container course' do + container = create(:course, preview: true) + listing = create(:course_assessment_marketplace_listing, course: container) + + expect(listing).to be_marketplace_hosted + end + + it 'stays true for a marketplace-hosted listing that is later unlisted' do + listing = create(:course_assessment_marketplace_listing, course: create(:course, preview: true)) + listing.update!(published: false) + + expect(listing.admin_state).to eq('unlisted') + expect(listing).to be_marketplace_hosted + end + + # The regression this method's `without_tenant` exists for. The real container lives in the + # dedicated preview instance, so every admin request asks from a different tenant — and a + # tenant-scoped `Course` lookup returns nil rather than raising, which would answer `false` for + # precisely the listings it identifies. The examples above use a same-instance container. + it 'sees the container even when the caller is tenanted to another instance' do + preview_instance = create(:instance) + container = ActsAsTenant.with_tenant(preview_instance) { create(:course, preview: true) } + copy = ActsAsTenant.with_tenant(preview_instance) { create(:assessment, course: container) } + # `publisher` passed explicitly: the factory default reads `authoring_assessment.course.creator`, + # which is itself tenant-scoped and would blow up here for the very reason under test. + listing = create(:course_assessment_marketplace_listing, authoring_assessment: copy, + publisher: create(:user)) + + expect(listing).to be_marketplace_hosted + end + + it 'is false for an orphaned listing, which has no authoring copy at all' do + listing = create(:course_assessment_marketplace_listing, course: create(:course, preview: true)) + listing.authoring_assessment.destroy! + + expect(listing.reload).not_to be_marketplace_hosted + end + end + + describe 'orphaning when the authoring assessment is deleted' do + let!(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + let!(:adoption) { create(:course_assessment_marketplace_adoption, listing: listing) } + + it 'survives with a null authoring assessment, keeping its versions and adoptions' do + expect { listing.authoring_assessment.destroy! }. + not_to(change { described_class.where(id: listing.id).count }) + + expect(listing.reload.authoring_assessment_id).to be_nil + expect(listing.versions.count).to eq(1) + expect(listing.adoptions).to include(adoption) + end + + it 'permits a second orphaned listing to coexist' do + first = create(:course_assessment_marketplace_listing) + second = create(:course_assessment_marketplace_listing) + first.authoring_assessment.destroy! + + expect { second.authoring_assessment.destroy! }. + not_to(change { described_class.where(id: [first.id, second.id]).count }) + + expect(first.reload.authoring_assessment_id).to be_nil + expect(second.reload.authoring_assessment_id).to be_nil + end + end end end diff --git a/spec/models/course/assessment/marketplace/listing_version_spec.rb b/spec/models/course/assessment/marketplace/listing_version_spec.rb new file mode 100644 index 00000000000..c1ec8b127dc --- /dev/null +++ b/spec/models/course/assessment/marketplace/listing_version_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ListingVersion, type: :model do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:listing) { create(:course_assessment_marketplace_listing) } + + describe 'validations' do + it 'is valid with the factory' do + expect(build(:course_assessment_marketplace_listing_version, listing: listing)).to be_valid + end + + it 'requires a published_at' do + version = build(:course_assessment_marketplace_listing_version, listing: listing, published_at: nil) + expect(version).not_to be_valid + expect(version.errors[:published_at]).to be_present + end + + it 'requires an assessment' do + version = build(:course_assessment_marketplace_listing_version, listing: listing, assessment: nil) + expect(version).not_to be_valid + expect(version.errors[:assessment]).to be_present + end + + it 'requires a publisher' do + version = build(:course_assessment_marketplace_listing_version, listing: listing, published_by: nil) + expect(version).not_to be_valid + expect(version.errors[:published_by]).to be_present + end + + it 'enforces published_at uniqueness scoped to the listing' do + published = 3.days.ago.change(usec: 0) + create(:course_assessment_marketplace_listing_version, listing: listing, published_at: published) + duplicate = build(:course_assessment_marketplace_listing_version, listing: listing, + published_at: published) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:published_at]).to be_present + end + + it 'allows the same published_at on a different listing' do + published = 3.days.ago.change(usec: 0) + create(:course_assessment_marketplace_listing_version, listing: listing, published_at: published) + other = build(:course_assessment_marketplace_listing_version, + listing: create(:course_assessment_marketplace_listing), published_at: published) + expect(other).to be_valid + end + end + + describe 'associations' do + it 'belongs to a listing, snapshot assessment, and publisher' do + version = create(:course_assessment_marketplace_listing_version, listing: listing) + expect(version.listing).to eq(listing) + expect(version.assessment).to be_a(Course::Assessment) + expect(version.published_by).to be_a(User) + end + end + + describe '.ordered' do + it 'orders by ascending published_at' do + later = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: 1.day.ago) + earlier = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: 5.days.ago) + expect(listing.versions.ordered).to eq([earlier, later]) + end + end + + describe '.labels_for_assessments' do + let(:listing) do + create(:course_assessment_marketplace_listing, source_course_name: 'MP Allowlist Source Course') + end + let(:published) { 4.days.ago.change(usec: 0) } + let!(:version) do + create(:course_assessment_marketplace_listing_version, listing: listing, published_at: published) + end + + it 'maps a snapshot to its listing, vintage and denormalised provenance' do + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listing_id]).to eq(listing.id) + expect(labels[version.assessment_id][:published_at]).to be_within(1.second).of(published) + expect(labels[version.assessment_id][:source]).to eq('MP Allowlist Source Course') + end + + it 'omits assessments that are not snapshots' do + plain = create(:assessment) + + labels = described_class.labels_for_assessments([version.assessment_id, plain.id]) + + expect(labels.keys).to eq([version.assessment_id]) + end + + it 'issues no query for an empty id list' do + expect(described_class).not_to receive(:joins) + expect(described_class.labels_for_assessments([])).to eq({}) + end + + # The restored working copy lives in the container beside the snapshots and is NOT a version, + # so it has no row here — it is found through the listing's authoring_assessment_id instead. + # Without this it is the one assessment in the container with no chip at all. + it 'labels the listing authoring copy with a null vintage' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy) + + labels = described_class.labels_for_assessments([working_copy.id]) + + expect(labels[working_copy.id][:published_at]).to be_nil + expect(labels[working_copy.id][:listing_id]).to eq(listing.id) + end + + it 'labels a snapshot and a working copy in one call' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy) + + labels = described_class.labels_for_assessments([version.assessment_id, working_copy.id]) + + expect(labels.keys).to contain_exactly(version.assessment_id, working_copy.id) + end + + it 'omits an assessment that is neither a snapshot nor a working copy' do + plain = create(:assessment) + + expect(described_class.labels_for_assessments([plain.id])).to eq({}) + end + + # `current_version_id` is the pointer the marketplace actually serves from, so the flag reads it + # rather than recomputing MAX(published_at). The two can disagree: an unlisted listing still has + # a newest snapshot, and a listing can be pointed back at an older cut deliberately. + it 'marks the current version as the latest' do + listing.update!(current_version: version) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:latest]).to be(true) + end + + it 'does not mark a superseded snapshot as the latest' do + pointed_at = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: 1.day.ago) + listing.update!(current_version: pointed_at) + + labels = described_class.labels_for_assessments([version.assessment_id, pointed_at.assessment_id]) + + expect(labels[version.assessment_id][:latest]).to be(false) + expect(labels[pointed_at.assessment_id][:latest]).to be(true) + end + + it 'marks nothing as the latest when the listing has no current version' do + listing.update!(current_version: nil) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:latest]).to be(false) + end + + # The working copy is not a version at all — it has no row in this table — so it can never be + # the latest one, even while the listing points at a perfectly good current version. + it 'never marks the authoring copy as the latest' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy, current_version: version) + + labels = described_class.labels_for_assessments([working_copy.id]) + + expect(labels[working_copy.id][:latest]).to be(false) + end + + # A listing id is a primary key: deleting a neighbouring listing renumbers nothing, and the flag + # is read off THIS listing's own pointer. This is the case that motivated the feature — an admin + # deleted listing 3 and expected listing 4 to become 3. + it 'is unaffected by the deletion of another listing' do + listing.update!(current_version: version) + other = create(:course_assessment_marketplace_listing) + create(:course_assessment_marketplace_listing_version, listing: other) + other.destroy! + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listing_id]).to eq(listing.id) + expect(labels[version.assessment_id][:latest]).to be(true) + end + + it 'reports a published listing as listed' do + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listed]).to be(true) + end + + it 'reports an unlisted listing as not listed' do + listing.update!(published: false) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listed]).to be(false) + end + + # `listed` reads the `published` COLUMN, never `admin_state`: an orphaned listing has lost its + # authoring copy but goes on serving its last snapshot and stays published. Reading the raw + # column avoids coupling this query to what `admin_state` currently means. + it 'still reports an orphaned but published listing as listed' do + listing.update!(authoring_assessment: nil) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listed]).to be(true) + end + + # Listing state belongs to the LISTING, so the working copy carries it too — its row is as + # unlisted as every snapshot of the same listing. + it 'reports the listing state on the authoring copy too' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy, published: false) + + labels = described_class.labels_for_assessments([working_copy.id]) + + expect(labels[working_copy.id][:listed]).to be(false) + end + end + + # `published_at` is a plain column. The v1 special case that used to live in a method here moved + # to write time in PublishService#cut_first_version!, where the listing's first-publication date + # is what actually dates the content. + describe '#published_at' do + it 'reads the column verbatim, with no version-dependent branch' do + published = 3.months.ago.change(usec: 0) + listing.update!(first_published_at: 1.year.ago) + version = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: published) + + expect(version.published_at).to be_within(1.second).of(published) + end + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 4564124a913..0606e471b9f 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -5,8 +5,7 @@ let!(:instance) { Instance.default } with_tenant(:instance) do let(:course) { create(:course) } - let(:listing) { create(:course_assessment_marketplace_listing, published: true) } - let(:published_assessment) { listing.assessment } + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } subject { Ability.new(user, course, course_user) } @@ -23,14 +22,20 @@ it { is_expected.to be_able_to(:access_marketplace, course) } it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } - it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } - it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, listing) } + it { is_expected.to be_able_to(:preview_in_marketplace, listing) } it 'cannot duplicate/preview an unpublished listing' do - unpublished = create(:course_assessment_marketplace_listing, published: false).assessment + unpublished = create(:course_assessment_marketplace_listing, :versioned, published: false) expect(subject).not_to be_able_to(:duplicate_from_marketplace, unpublished) expect(subject).not_to be_able_to(:preview_in_marketplace, unpublished) end + + it 'authorizes a listing whose served snapshot sits in a course the user cannot reach' do + remote = create(:course_assessment_marketplace_listing, :versioned, published: true) + expect(subject).to be_able_to(:duplicate_from_marketplace, remote) + expect(subject).to be_able_to(:preview_in_marketplace, remote) + end end context 'when the user is a course student' do @@ -57,8 +62,8 @@ end it { is_expected.to be_able_to(:access_marketplace, course) } - it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } - it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, listing) } + it { is_expected.to be_able_to(:preview_in_marketplace, listing) } end context 'when an allow-listed user manages no course at all' do @@ -112,5 +117,55 @@ expect(Ability.new(user, course, course_user)).to be_able_to(:access_marketplace, course) end end + + context 'when the course is a preview (content-frozen) sandbox' do + let(:course) { create(:course, preview: true) } + let(:assessment) { create(:assessment, course: course) } + + context 'and the user is the previewer (a course manager)' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + it 'preserves the attempt + publish loop' do + expect(subject).to be_able_to(:attempt, assessment) + expect(subject).to be_able_to(:publish_grades, assessment) + end + + it 'freezes the assessment content (no edit/delete)' do + expect(subject).not_to be_able_to(:update, assessment) + expect(subject).not_to be_able_to(:destroy, assessment) + end + + it 'freezes question authoring' do + expect(subject).not_to be_able_to(:create, Course::Assessment::Question::MultipleResponse) + end + + it 'forbids deleting submissions in the sandbox' do + expect(subject).not_to be_able_to(:delete_all_submissions, assessment) + end + end + + context 'and the user is a system administrator' do + let(:user) { create(:administrator) } + let(:course_user) { nil } + + it 'is exempt — retains full content management' do + expect(subject).to be_able_to(:update, assessment) + expect(subject).to be_able_to(:destroy, assessment) + end + end + end + + context 'when a course manager is in a NON-preview course' do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + it 'retains normal content management (the freeze is preview-scoped)' do + expect(subject).to be_able_to(:update, assessment) + expect(subject).to be_able_to(:destroy, assessment) + end + end end end diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index 262e63dc095..24fe667da20 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -349,5 +349,24 @@ it { is_expected.to eq(course.course_users.student.count) } end end + + describe 'the preview flag' do + it 'defaults to false for a new course' do + expect(build(:course).preview).to eq(false) + end + + it 'is invalid when preview is nil' do + course = build(:course) + course.preview = nil + expect(course).not_to be_valid + expect(course.errors[:preview]).to be_present + end + + it 'is valid when preview is true' do + course = build(:course) + course.preview = true + expect(course).to be_valid + end + end end end diff --git a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb new file mode 100644 index 00000000000..dc5af625717 --- /dev/null +++ b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PreviewContainerService, type: :service do + # The dedicated preview instance/course are cross-tenant singletons created by the service itself, + # so this spec runs under the default tenant and lets the service switch tenants internally. + let!(:default_instance) { Instance.default } + + with_tenant(:default_instance) do + describe '.preview_instance' do + it 'returns the dedicated non-default preview instance, idempotently' do + first = described_class.preview_instance + second = described_class.preview_instance + + expect(second).to eq(first) + expect(first).not_to be_default + expect(first.read_attribute(:host)).to eq(described_class::PREVIEW_INSTANCE_HOST) + expect(first.name).to eq(described_class::PREVIEW_INSTANCE_NAME) + end + end + + describe '.container_course' do + it 'returns a single preview-flagged container course in the preview instance, idempotently' do + first = described_class.container_course + second = described_class.container_course + + expect(second).to eq(first) + expect(first).to be_preview + expect(first.instance).to eq(described_class.preview_instance) + expect(first.title).to eq(described_class::PREVIEW_COURSE_TITLE) + end + + it 'does not create a second container course on the second call' do + described_class.container_course + expect { described_class.container_course }. + not_to(change do + ActsAsTenant.with_tenant(described_class.preview_instance) do + Course.where(preview: true).count + end + end) + end + + # The container holds every published version snapshot, so it must never surface as a course + # in its own right — not in a listing, not via self-enrolment, not to any user but the system + # one. Previewers are attached explicitly, one at a time. + it 'is unpublished, ungamified and not self-enrollable' do + container = described_class.container_course + + expect(container.published).to be(false) + expect(container.gamified).to be(false) + expect(container.enrollable).to be(false) + end + + # Only `creator` is asserted. `updater` is deliberately NOT an invariant: the container is a + # long-lived singleton that every publish snapshots into, and those writes re-stamp it with + # the publisher. Asserting `updater == User.system` only passes on a container no one has + # published into yet. + it 'is created by the system user' do + container = described_class.container_course + + expect(container.creator).to eq(User.system) + end + + it 'is not publicly accessible' do + container = described_class.container_course + + ActsAsTenant.without_tenant do + expect(Course.publicly_accessible).not_to include(container) + end + end + + it 'enrolls only the system user, so no other user ever sees it' do + container = described_class.container_course + # Enroll other_user in an unrelated real course so the negative assertion is non-vacuous: + # containing_user DOES surface a course they belong to, yet never the container. + other_course = create(:course) + other_user = create(:course_manager, course: other_course).user + + ActsAsTenant.without_tenant do + expect(Course.containing_user(User.system)).to include(container) + expect(Course.containing_user(other_user)).to include(other_course) + expect(Course.containing_user(other_user)).not_to include(container) + end + end + end + end +end diff --git a/spec/services/course/assessment/marketplace/publish_service_spec.rb b/spec/services/course/assessment/marketplace/publish_service_spec.rb new file mode 100644 index 00000000000..8964bb41496 --- /dev/null +++ b/spec/services/course/assessment/marketplace/publish_service_spec.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PublishService, type: :service do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:publisher) { create(:user) } + + def container + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + describe '.publish' do + it 'activates the listing and cuts version 1 published by the publisher' do + listing = described_class.publish(assessment, publisher) + expect(listing.published).to be(true) + expect(listing.current_version).to be_present + expect(listing.current_version.published_at).to eq(listing.first_published_at) + expect(listing.current_version.published_by).to eq(publisher) + end + + it 'snapshots a distinct copy of the assessment into the container course' do + listing = described_class.publish(assessment, publisher) + snapshot = listing.current_version.assessment + ActsAsTenant.without_tenant do + expect(snapshot).not_to eq(assessment) + expect(snapshot.course).to eq(container) + end + end + + it 'creates exactly one version row' do + expect { described_class.publish(assessment, publisher) }. + to change { Course::Assessment::Marketplace::ListingVersion.count }.by(1) + end + + it 'captures denormalized provenance from the source course' do + listing = described_class.publish(assessment, publisher) + expect(listing.source_course).to eq(course) + expect(listing.source_course_name).to eq(course.title) + expect(listing.fallback_maintainer).to eq(course.course_users.find_by(role: :owner).user) + end + + it 'does not cut a second version when re-published (first-publish only this slice)' do + described_class.publish(assessment, publisher) + expect { described_class.publish(assessment, publisher) }. + not_to(change { Course::Assessment::Marketplace::ListingVersion.count }) + end + + it 'preserves first_published_at and bumps last_published_at on re-publish' do + old = 3.days.ago + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + published: false, + first_published_at: old, + last_published_at: old) + result = described_class.publish(assessment, publisher) + expect(result.id).to eq(listing.id) + expect(result.first_published_at).to be_within(1.second).of(old) + expect(result.last_published_at).to be > old + end + end + + describe '.ensure_first_version!' do + let(:listing) do + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) + end + + it 'cuts version 1 for a published listing that has none' do + expect(listing.current_version).to be_nil + version = described_class.ensure_first_version!(listing, publisher) + expect(version.published_at).to eq(listing.first_published_at) + expect(listing.reload.current_version).to eq(version) + end + + it 'is idempotent: a second call cuts no new version' do + described_class.ensure_first_version!(listing, publisher) + expect { described_class.ensure_first_version!(listing, publisher) }. + not_to(change { Course::Assessment::Marketplace::ListingVersion.count }) + end + + it 'captures provenance during the version cut' do + described_class.ensure_first_version!(listing, publisher) + expect(listing.reload.source_course).to eq(course) + expect(listing.source_course_name).to eq(course.title) + end + end + + describe '.publish_new_version' do + let!(:listing) { described_class.publish(assessment, publisher) } + let(:cutter) { create(:user) } + + it 'cuts the next version from the authoring copy and advances current_version' do + version = described_class.publish_new_version(listing.reload, cutter) + + expect(version.published_at).to eq(listing.reload.last_published_at) + expect(version.published_by).to eq(cutter) + expect(listing.reload.current_version).to eq(version) + end + + it 'snapshots into the container as a copy distinct from the authoring assessment' do + version = described_class.publish_new_version(listing.reload, cutter) + + ActsAsTenant.without_tenant do + expect(version.assessment).not_to eq(listing.authoring_assessment) + expect(version.assessment.course).to eq(container) + end + end + + it 'retains the previous snapshot' do + v1 = listing.current_version + + expect { described_class.publish_new_version(listing.reload, cutter) }. + to change { listing.reload.versions.count }.by(1) + expect(v1.reload).to be_persisted + end + + it 'adds exactly one assessment to the container per cut' do + expect { described_class.publish_new_version(listing.reload, cutter) }. + to change { ActsAsTenant.without_tenant { container.assessments.count } }.by(1) + end + + it 'keeps advancing past the second cut' do + described_class.publish_new_version(listing.reload, cutter) + third = described_class.publish_new_version(listing.reload, cutter) + + expect(third.published_at).to eq(listing.reload.last_published_at) + end + + it 'bumps last_published_at' do + listing.update!(last_published_at: 3.days.ago) + + expect { described_class.publish_new_version(listing.reload, cutter) }. + to(change { listing.reload.last_published_at }) + end + + it 'raises when the listing is orphaned' do + listing.update!(authoring_assessment: nil) + + expect { described_class.publish_new_version(listing.reload, cutter) }. + to raise_error(ArgumentError, /orphaned/) + end + end + + describe 'provenance capture' do + it 'records the source course name at publish' do + listing = described_class.publish(assessment, publisher) + + expect(listing.source_course).to eq(course) + expect(listing.source_course_name).to eq(course.title) + end + + # `Course` is tenanted by instance, so the origin instance is what makes the recorded course id + # resolvable at all — and what tells two courses of the same name in different instances apart. + it 'records the source instance at publish' do + listing = described_class.publish(assessment, publisher) + + expect(listing.source_instance).to eq(instance) + end + + it 'records the source instance during a version cut that repairs provenance' do + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + published: true) + listing.update_columns(source_instance_id: nil) + + described_class.ensure_first_version!(listing, publisher) + + expect(listing.reload.source_instance).to eq(instance) + end + + # `||=`, like every sibling provenance field: provenance is what was true at first publish, and + # a later re-publish (possibly from a course moved between instances) must not rewrite history. + it 'does not overwrite a source instance already captured when re-published' do + origin_instance = create(:instance) + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + published: false, + source_instance: origin_instance) + + described_class.publish(assessment, publisher) + + expect(listing.reload.source_instance).to eq(origin_instance) + end + end + + describe '.backfill_source_instances!' do + it 'fills the instance from a surviving source course' do + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + source_course: course) + listing.update_columns(source_instance_id: nil) + + expect { described_class.backfill_source_instances! }. + to change { listing.reload.source_instance }.from(nil).to(instance) + end + + # Documented limitation: an already-orphaned listing has no source course for the backfill to + # read, and nothing else on the row identifies its origin. It stays NULL forever, by design. + it 'leaves an already-orphaned listing with no source course NULL' do + orphan = create(:course_assessment_marketplace_listing) + orphan.update_columns(source_course_id: nil, source_instance_id: nil) + + described_class.backfill_source_instances! + + expect(orphan.reload.source_instance).to be_nil + end + + it 'is idempotent: an instance already recorded is not overwritten' do + origin_instance = create(:instance) + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + source_course: course, + source_instance: origin_instance) + + described_class.backfill_source_instances! + + expect(listing.reload.source_instance).to eq(origin_instance) + end + end + + describe '.backfill_all!' do + it 'snapshots each published, version-less listing as v1 and sets adopted_version_at' do + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + + described_class.backfill_all! + + expect(listing.reload.current_version.published_at).to eq(listing.first_published_at) + expect(adoption.reload.adopted_version_at).to eq(listing.current_version.published_at) + end + + it 'leaves an already-versioned listing untouched' do + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) + described_class.ensure_first_version!(listing, publisher) + original_version_id = listing.reload.current_version_id + + described_class.backfill_all! + + expect(listing.reload.current_version_id).to eq(original_version_id) + end + + it 'ignores unpublished listings' do + unpublished = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: false) + described_class.backfill_all! + expect(unpublished.reload.current_version).to be_nil + end + + # An orphan has no authoring copy to snapshot. Before this guard the backfill raised partway + # through and left every listing after the orphan unversioned. + it 'skips orphaned listings and still versions the rest' do + orphan = create(:course_assessment_marketplace_listing, published: true) + orphan.authoring_assessment.destroy! + healthy = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) + + expect { described_class.backfill_all! }.not_to raise_error + + expect(orphan.reload.current_version).to be_nil + expect(healthy.reload.current_version.published_at).to eq(healthy.first_published_at) + end + end + + describe 'version publication dates' do + let(:publisher) { create(:user) } + + it 'dates v1 from the listing first-publication date, not the moment of the cut' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + + expect(listing.current_version.published_at). + to be_within(1.second).of(listing.first_published_at) + end + + # A listing that somehow reaches the cut with no first-publication date must still produce a + # NOT NULL column rather than blowing up mid-publish. + it 'falls back to now when the listing has no first-publication date' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + listing.update_columns(first_published_at: nil, current_version_id: nil) + listing.versions.destroy_all + + version = described_class.ensure_first_version!(listing.reload, publisher) + + expect(version.published_at).to be_within(5.seconds).of(Time.zone.now) + end + + it 'dates a later cut from the moment of the cut' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + listing.update!(first_published_at: 30.days.ago) + + version = described_class.publish_new_version(listing, publisher) + + expect(version.published_at).to be_within(5.seconds).of(Time.zone.now) + end + + # One `Time.zone.now`, written twice. Two separate calls would leave the version row and the + # listing disagreeing by milliseconds, and the admin table reads one while the history reads + # the other. + it 'writes the identical instant to the version row and the listing' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + + version = described_class.publish_new_version(listing, publisher) + + expect(version.published_at).to eq(listing.reload.last_published_at) + end + + it 'orders successive cuts by ascending published_at' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + second = described_class.publish_new_version(listing, publisher) + + expect(listing.versions.ordered.last).to eq(second) + expect(listing.reload.current_version).to eq(second) + end + end + end +end From 0bbcfead91e48684109b46a50e41c01e3e63629b Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:36 +0800 Subject: [PATCH 05/28] feat(marketplace): rebuild or purge a listing that lost its source Deleting the source assessment now orphans the listing instead of destroying it: the marketplace keeps serving the snapshot, and the authoring copy is rebuilt in the container automatically so a new version can still be published. --- .../marketplace/restore_authoring_job.rb | 69 ++++++++ app/models/course/assessment.rb | 123 ++++++++++++- .../assessment/marketplace/purge_service.rb | 57 ++++++ .../marketplace/restore_authoring_job_spec.rb | 156 ++++++++++++++++ spec/models/course/assessment_spec.rb | 167 ++++++++++++++++++ .../marketplace/purge_service_spec.rb | 134 ++++++++++++++ 6 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 app/jobs/course/assessment/marketplace/restore_authoring_job.rb create mode 100644 app/services/course/assessment/marketplace/purge_service.rb create mode 100644 spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb create mode 100644 spec/services/course/assessment/marketplace/purge_service_spec.rb diff --git a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb new file mode 100644 index 00000000000..9fde447df12 --- /dev/null +++ b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +# Un-orphans a listing (design §5.3): duplicates the listing's latest snapshot into the marketplace's +# own container course as a NEW, editable assessment and points `authoring_assessment` at it, so +# `PublishService.publish_new_version` works again. +# +# The destination is the container, not a live course. Restore is not recovering some instructor's +# deleted assessment — it recovers the LISTING's ability to publish, from the marketplace's own +# snapshot. Putting the working copy anywhere else injects an assessment into a course somebody owns +# in order to fix a marketplace-owned problem. +# +# The copy is a NEW assessment sitting alongside the immutable snapshots — never one of them. Editing +# a snapshot in place would mutate a published version for every adopter with no version cut and make +# `adoptions.adopted_version` a lie. +# +# The container's content freeze does not obstruct this: `restrict_preview_course_content` is reached +# only via `define_non_admin_course_permissions`, guarded by `!user&.administrator?` +# (assessment_marketplace_ability_component.rb:21, :37), and every marketplace write is already +# admin-only. So an admin can edit the working copy and cut v(n+1) from it in place. +class Course::Assessment::Marketplace::RestoreAuthoringJob < ApplicationJob + include TrackableJob + include Rails.application.routes.url_helpers + + queue_as :duplication + + protected + + def perform_tracked(listing_id, options = {}) + current_user = options[:current_user] + # The container lives in the dedicated preview instance, never the caller's. + ActsAsTenant.without_tenant do + listing = Course::Assessment::Marketplace::Listing.find(listing_id) + # Re-checked here and not only in the controller: the listing can be republished (which + # restores an authoring copy on its own) between enqueue and perform, and this job is the only + # writer of `authoring_assessment`. It is a column read on a loaded record. + # + # A listing that already has one is DONE, not failed — the end state this job exists to reach is + # the one it found. That race became ordinary rather than exceptional when the rebuild started + # being enqueued automatically on deletion (Course::Assessment#rebuild_marketplace_listing_authoring), + # so it must not surface as a failed job to the admin who happens to be watching. + return unless listing.orphaned? + raise ArgumentError, 'listing is not restorable' unless listing.restorable? + + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + copy = restore_authoring_copy(listing, container, current_user) + redirect_to course_assessment_url(container, copy, host: container.instance.host) + end + end + + private + + # @return [Course::Assessment] the fresh authoring copy + def restore_authoring_copy(listing, container, current_user) + # The SNAPSHOT, so the restored copy is exactly what the marketplace currently serves. + source = listing.current_version.assessment + User.with_stamper(current_user) do + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( + source.course, container, source, current_user: current_user + ) + # The restored copy is a standalone assessment, not a link-sibling of the container snapshot + # and of every adopter's copy. See Course::Assessment#detach_from_link_tree!. + copy.detach_from_link_tree! + # `source_course` and `source_course_name` are deliberately left untouched: they record where + # the content originally came from, a historical fact. Restoring is a maintenance action on the + # listing, not a republish from a new origin, so rewriting provenance would falsify its history. + listing.update!(authoring_assessment: copy) + copy + end + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index e489dce65d0..1e8c9f2e849 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -19,6 +19,9 @@ class Course::Assessment < ApplicationRecord after_create :set_linkable_tree_id after_commit :grade_with_new_test_cases, on: :update before_save :save_tab + # See #rebuild_marketplace_listing_authoring for why the pair is split across the two callbacks. + before_destroy :remember_orphaned_marketplace_listing + after_commit :rebuild_marketplace_listing_authoring, on: :destroy enum :randomization, { prepared: 0 } @@ -82,8 +85,12 @@ class Course::Assessment < ApplicationRecord has_one :gradebook_assessment_contribution, class_name: 'Course::Gradebook::AssessmentContribution', dependent: :destroy, inverse_of: :assessment + # `dependent: :nullify`, NOT `:destroy`: deleting the source assessment must ORPHAN the listing, + # not destroy it along with its version chain and every adopter's adoption record. The model + # callback fires before the DB, so this and the FK's `on_delete: :nullify` must move together. has_one :marketplace_listing, class_name: 'Course::Assessment::Marketplace::Listing', - inverse_of: :assessment, dependent: :destroy + foreign_key: :authoring_assessment_id, + inverse_of: :authoring_assessment, dependent: :nullify has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', inverse_of: :assessment, dependent: :destroy has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy @@ -126,6 +133,26 @@ class Course::Assessment < ApplicationRecord merge(Course::LessonPlan::Item.ordered_by_date_and_title) end) + # Every assessment title already taken in a course, downcased for case-insensitive comparison. + # + # An assessment's title lives on its lesson-plan item (`acts_as_lesson_plan_item`), so this joins + # rather than plucking a column off `course_assessments`. Scoped to the whole COURSE, not a tab: + # a duplicate title two tabs away is exactly as confusing as one sitting next to it. + # + # Downcased because "Lab 3" and "lab 3" side by side is the confusion the collision rule exists to + # prevent — a case-sensitive comparison would let them coexist. + # + # @param [Course] course + # @param [Integer, nil] except_id an assessment to leave out — the in-place update overwrites its + # own title and must not collide with itself. + # @return [Array] + def self.titles_in_course(course, except_id: nil) + scope = course.assessments.joins(:lesson_plan_item) + scope = scope.where.not(id: except_id) if except_id + + scope.pluck('LOWER(course_lesson_plan_items.title)') + end + # @!method with_submissions_by(creator) # Includes the submissions by the provided user. # @param [User] user The user to preload submissions for. @@ -185,6 +212,36 @@ def to_partial_path 'course/assessment/assessments/assessment' end + # Splits this assessment's submissions into those by real students of its course and everything + # else, which is what decides whether its content may be replaced in place. + # + # A LEFT JOIN, deliberately: a submission whose author has since left the course has no + # `course_users` row, and an INNER JOIN would drop it from BOTH counts - making a copy look + # untouched when it is not. It lands in `other`. + # + # Submissions carry no `course_user_id`, so course membership is resolved through + # `creator_id` + `course_id`. `role = 0` is `student` on Course::CourseUser's enum. + # + # Workflow state is deliberately not considered: an untouched `attempting` draft is still a + # student's attempt, and destroying it would be destroying their work. + # + # @return [Hash{Symbol => Integer}] + def submission_counts_by_author + sql = self.class.sanitize_sql_array([<<-SQL.squish, course.id, id]) + SELECT + COUNT(*) FILTER (WHERE cu.id IS NOT NULL AND cu.role = 0 AND cu.phantom = FALSE) + AS student_count, + COUNT(*) FILTER (WHERE cu.id IS NULL OR cu.role <> 0 OR cu.phantom = TRUE) + AS other_count + FROM course_assessment_submissions s + LEFT JOIN course_users cu ON cu.user_id = s.creator_id AND cu.course_id = ? AND cu.deleted_at IS NULL + WHERE s.assessment_id = ? + SQL + row = self.class.connection.select_one(sql) + + { student: row['student_count'].to_i, other: row['other_count'].to_i } + end + # Update assessment mode from params. # # @param [Hash] params Params with autograded mode from user. @@ -315,8 +372,72 @@ def all_linked_assessments ([self] + linked_assessments.includes(:course, :submissions)).uniq end + # Makes this assessment a standalone link tree of one. + # + # Marketplace distribution is not "linking". `initialize_duplicate` propagates the source's + # `linkable_tree_id` and rebuilds `linked_assessments` — right for course duplication, wrong here: + # without this the container snapshot, the origin, and every adopter's copy become mutual + # `linked_assessments`, exposing ids across unrelated courses and crashing onward duplication. + # + # Called on the snapshot at publish time and on the adopted copy at duplication time. + def detach_from_link_tree! + links.destroy_all + reverse_links.destroy_all + update_column(:linkable_tree_id, id) + end + + # Whether this assessment is a published version of some listing — one of the immutable snapshots + # `Course::Assessment::Marketplace::PublishService` duplicates into the container course. + # + # A snapshot is an existing listing's content, never a source assessment, so it must not be + # publishable in its own right: the listing that would create has its source frozen inside the + # container, so nobody could edit it or cut a further version. Nothing on the assessment row says + # this — a snapshot carries the origin's title verbatim — so the version rows are the only tell. + # + # Deliberately keyed on the version rows rather than on the container's `preview` flag: an + # assessment authored directly in the container is not a snapshot and stays publishable. + # + # @return [Boolean] + def marketplace_snapshot? + Course::Assessment::Marketplace::ListingVersion.exists?(assessment_id: id) + end + private + # The listing this assessment authors, if losing it would orphan a listing that can be rebuilt. + # + # Read here rather than in the `after_commit` because `dependent: :nullify` clears the association + # during the destroy — with `update_columns`, which fires no callbacks, so the listing side offers + # nothing to hook. Only listings holding a published version are remembered: the rebuild duplicates + # the latest snapshot, so one that never published has nothing to rebuild from. + def remember_orphaned_marketplace_listing + listing = marketplace_listing + @orphaned_marketplace_listing_id = listing&.current_version_id ? listing.id : nil + end + + # Rebuilds the listing's authoring copy in the marketplace container as soon as its source is gone. + # The marketplace serves the snapshot either way — what orphaning costs is the ability to publish a + # new version, which an admin would otherwise learn only by visiting the listings table. Doing it + # automatically makes the manual "Rebuild source assessment" action only ever a retry. + # + # This is the single choke point for both ways a listing loses its source: a course deletion + # cascades to its assessments through Ruby `dependent: :destroy` (course -> categories -> tabs -> + # assessments), so it arrives here too. + # + # `after_commit`, never `after_destroy`: a course deletion destroys every one of its assessments + # inside one transaction, so a job enqueued mid-destroy could be picked up before the deletion is + # durable — or after a sibling's failure rolled the whole thing back, rebuilding a listing that was + # never orphaned at all. + # + # `User.system` because there is no acting user in a cascade, and the rebuild has to be attributable + # to something: the job stamps the duplicated copy's creator and the listing's updater. + def rebuild_marketplace_listing_authoring + return if @orphaned_marketplace_listing_id.nil? + + Course::Assessment::Marketplace::RestoreAuthoringJob. + perform_later(@orphaned_marketplace_listing_id, current_user: User.system) + end + # Parents the assessment under its duplicated parent tab, if it exists. # # @return [Course::Assessment::Tab] The duplicated assessment's tab diff --git a/app/services/course/assessment/marketplace/purge_service.rb b/app/services/course/assessment/marketplace/purge_service.rb new file mode 100644 index 00000000000..f6915c44635 --- /dev/null +++ b/app/services/course/assessment/marketplace/purge_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# Permanently deletes a marketplace listing together with the container snapshots it owns. +# +# Only ever allowed for a listing that is OFF the marketplace — orphaned or unlisted +# (`Listing#purgeable?`). A published listing must be unlisted (`published: false`) first, which is +# the reversible step. +# +# Purging destroys the listing's adoption rows too (`has_many :adoptions, dependent: :destroy`), but +# NOT the adopters' own duplicated assessments: `Adoption belongs_to :duplicated_assessment` carries a +# plain FK with no `dependent:` option, so destroying the adoption row never reaches into the +# destination course that assessment lives in. A purge must never touch another course's content. +# +# Purging an unlisted listing destroys the listing, its versions and their container snapshots, but +# NOT the authoring assessment those snapshots were copied from — so unlike the orphaned case the +# content survives and can be published afresh. +class Course::Assessment::Marketplace::PurgeService + # @param [Course::Assessment::Marketplace::Listing] listing + # @raise [ArgumentError] if the listing is not purgeable + # @return [void] + def self.purge!(listing) + new(listing).purge! + end + + def initialize(listing) + @listing = listing + end + + # @return [void] + def purge! + raise ArgumentError, 'only an orphaned or unlisted listing can be permanently deleted' unless + @listing.purgeable? + + # The snapshots live in the hidden container course, which sits in the dedicated preview + # instance — never the caller's. + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::Listing.transaction do + snapshot_ids = @listing.versions.pluck(:assessment_id) + @listing.destroy! + destroy_snapshots(snapshot_ids) + end + end + nil + end + + private + + # Ordering is load-bearing, and it is why the ids are collected before the listing is destroyed: + # `course_assessment_marketplace_listing_versions.assessment_id` carries a plain FK with no + # `on_delete`, so destroying a snapshot while its version row still references it raises + # PG::ForeignKeyViolation. Destroy the listing first (its `versions` cascade), then the snapshots. + # + # Skipping this second step would leak the snapshots: nothing else references them, so the + # container course would grow forever with no reclaim path. + def destroy_snapshots(snapshot_ids) + Course::Assessment.where(id: snapshot_ids).each(&:destroy!) + end +end diff --git a/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb new file mode 100644 index 00000000000..3471c0c1ef1 --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::RestoreAuthoringJob, type: :job do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + # Published through the real service so the snapshot genuinely lives in the container course in + # the preview instance: restoring must duplicate ACROSS instances, exactly as adoption does. + let(:listing) { Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) } + let(:user) { create(:administrator) } + + def container + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + # Deleting the authoring assessment nullifies `authoring_assessment_id` (`dependent: :nullify`), + # which is what "orphaned" means. + def orphan! + listing + source_assessment.destroy! + listing.reload + end + + def run + described_class.perform_now(listing.id, current_user: user) + end + + context 'when the listing is orphaned with a version' do + before { orphan! } + + it 'duplicates the snapshot into the container course' do + expect { run }.to change { container.assessments.count }.by(1) + end + + # A NEW assessment beside the snapshots, never one of them. Editing a snapshot would mutate a + # published version for every adopter with no version cut. + it 'creates a new assessment rather than reusing the snapshot' do + snapshot = listing.current_version.assessment + + run + + copy = listing.reload.authoring_assessment + expect(copy.id).not_to eq(snapshot.id) + expect(snapshot.reload).to be_persisted + expect(listing.current_version.reload.assessment_id).to eq(snapshot.id) + end + + # Pinned deliberately: this holds only because ObjectDuplicationService's object-mode default is + # `unpublish_all: true` and no caller overrides it. A published working copy in the container + # would be visible to PR8 previewers, so this must fail loudly if that default ever changes. + it 'lands the working copy as a draft' do + run + + expect(listing.reload.authoring_assessment.published).to be(false) + end + + it 'points the listing at the new copy, un-orphaning it' do + run + + copy = listing.reload.authoring_assessment + expect(listing.reload.authoring_assessment).to eq(copy) + expect(listing).not_to be_orphaned + end + + it 'carries the snapshot content into the copy' do + snapshot_title = ActsAsTenant.without_tenant { listing.current_version.assessment.title } + + run + + copy = listing.reload.authoring_assessment + expect(copy.title).to eq(snapshot_title) + expect(copy.questions.count).to eq(1) + end + + # Restoring maintenance access is not a course adopting the content. + it 'records no adoption' do + expect { run }.not_to change(Course::Assessment::Marketplace::Adoption, :count) + end + + # Same reason the adopted copy is detached: without this the restored copy, the container + # snapshot and every adopter's copy become mutual `linked_assessments`. + it 'leaves the restored copy in a link tree of its own' do + run + + copy = listing.reload.authoring_assessment + expect(copy.linkable_tree_id).to eq(copy.id) + expect(copy.all_linked_assessments).to contain_exactly(copy) + end + + # Provenance describes where the content originally came from. A maintenance action must not + # rewrite that historical fact. + it 'leaves the provenance fields untouched' do + provenance = [:source_course_id, :source_course_name] + before_restore = listing.slice(*provenance) + + run + + expect(listing.reload.slice(*provenance)).to eq(before_restore) + expect(listing.source_course).to eq(source_course) + end + + # The end-to-end proof for `#marketplace_hosted?`: the copy lands in the real container, so the + # admin table can tell a rebuilt listing from one that still has its own source course. + it 'reports the listing as marketplace-hosted afterwards' do + expect { run }.to change { listing.reload.marketplace_hosted? }.from(false).to(true) + end + + it 'leaves the current version untouched — restoring is not a republish' do + expect { run }.not_to(change { listing.reload.current_version_id }) + end + + it 'lets the listing cut a new version again' do + run + + expect do + Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) + end.to(change { listing.reload.current_version_id }) + end + end + + describe 'guards' do + # `perform_now` cannot be asserted with `raise_error`: TrackableJob installs + # `rescue_from(StandardError)`, so a refusal surfaces as an errored Job record instead. + def run_and_capture(target = listing) + job = described_class.new(target.id, current_user: user) + job.perform_now + job.job + end + + # Completes rather than errors, and rebuilds nothing: the end state this job exists to reach is + # the one it found. That race is ordinary now that the rebuild is enqueued automatically when an + # assessment is deleted — a republish can restore the authoring copy while the job sits in the + # queue — so it must not surface as a failure to whoever is watching. + it 'leaves a listing that already has an authoring copy alone' do + listing + + expect { run_and_capture }.not_to(change { container.assessments.count }) + expect(run_and_capture.status).to eq('completed') + end + + it 'refuses an orphaned listing with no version to restore from' do + versionless = create(:course_assessment_marketplace_listing, course: source_course) + versionless.authoring_assessment.destroy! + versionless.reload + + expect { run_and_capture(versionless) }. + not_to(change { container.assessments.count }) + expect(run_and_capture(versionless).status).to eq('errored') + end + end + end +end diff --git a/spec/models/course/assessment_spec.rb b/spec/models/course/assessment_spec.rb index 6d8b480816a..a6946e67200 100644 --- a/spec/models/course/assessment_spec.rb +++ b/spec/models/course/assessment_spec.rb @@ -436,5 +436,172 @@ expect(result).not_to have_key(empty_assessment.id) end end + + describe '.titles_in_course' do + let(:course) { create(:course) } + + it 'returns the downcased titles of every assessment in the course' do + create(:assessment, course: course, title: 'Lab 3') + create(:assessment, course: course, title: 'Tutorial 1') + + expect(described_class.titles_in_course(course)). + to contain_exactly('lab 3', 'tutorial 1') + end + + # A duplicate title two tabs away is exactly as confusing as one in the same tab, so the whole + # course is the collision scope. + it 'spans every tab and category in the course' do + other_category = create(:course_assessment_category, course: course) + other_tab = create(:course_assessment_tab, category: other_category) + create(:assessment, course: course, title: 'Lab 3') + create(:assessment, course: course, tab: other_tab, title: 'Lab 4') + + expect(described_class.titles_in_course(course)). + to contain_exactly('lab 3', 'lab 4') + end + + it 'ignores assessments in other courses' do + create(:assessment, course: course, title: 'Lab 3') + create(:assessment, course: create(:course), title: 'Foreign Lab') + + expect(described_class.titles_in_course(course)).to eq(['lab 3']) + end + + # The in-place update overwrites an assessment's OWN title, so it must not collide with itself. + it 'excludes the named assessment' do + create(:assessment, course: course, title: 'Lab 3') + self_assessment = create(:assessment, course: course, title: 'Lab 4') + + expect(described_class.titles_in_course(course, except_id: self_assessment.id)). + to eq(['lab 3']) + end + + it 'returns an empty array for a course with no assessments' do + expect(described_class.titles_in_course(create(:course))).to eq([]) + end + end + + describe '#submission_counts_by_author' do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, :with_mcq_question, course: course) } + + it 'is all zeroes for an assessment nobody has attempted' do + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 0) + end + + # A real student's work blocks the in-place update in ANY workflow state - an untouched + # `attempting` draft is still their attempt. + it 'counts a non-phantom student attempt, even while merely attempting' do + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: assessment, creator: student.user) + + expect(assessment.submission_counts_by_author).to eq(student: 1, other: 0) + end + + it 'counts a submitted student submission' do + student = create(:course_student, course: course) + create(:submission, :submitted, assessment: assessment, creator: student.user) + + expect(assessment.submission_counts_by_author).to eq(student: 1, other: 0) + end + + # An instructor's own test run must not permanently cost them the update option. + it 'counts a manager test run as other, not student' do + manager = create(:course_manager, course: course) + create(:submission, :attempting, assessment: assessment, creator: manager.user) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + it 'counts a phantom student test run as other, not student' do + phantom = create(:course_student, :phantom, course: course) + create(:submission, :attempting, assessment: assessment, creator: phantom.user) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + # A submission whose author has since left the course has no course_user row to classify it. + # It must land in `other` rather than vanishing from both counts. + it 'counts a submission by a departed user as other' do + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: assessment, creator: student.user) + student.destroy! + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + it 'counts a submission by a soft-deleted course user as other' do + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: assessment, creator: student.user) + student.update!(deleted_at: Time.zone.now) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + it 'ignores submissions on a different assessment' do + other_assessment = create(:assessment, :with_mcq_question, course: course) + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: other_assessment, creator: student.user) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 0) + end + end + + # Deleting the source assessment ORPHANS its listing rather than destroying it: the marketplace + # goes on serving the last snapshot, but nobody can publish a new version of it again. Rebuilding + # the authoring copy is what restores that, and it is automatic so an admin never has to notice + # the breakage first — the "Rebuild source assessment" action remains only as a manual retry. + describe 'automatic marketplace authoring rebuild' do + with_active_job_queue_adapter(:test) do + let(:listing) do + create(:course_assessment_marketplace_listing, :versioned, course: course) + end + let(:listing_without_version) do + create(:course_assessment_marketplace_listing, course: course) + end + + it 'enqueues a rebuild when the source assessment is deleted' do + listed_assessment = listing.authoring_assessment + + expect { listed_assessment.destroy! }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing.id, current_user: User.system) + end + + # A course deletion cascades to its assessments through Ruby `dependent: :destroy`, so the one + # hook on the assessment covers both ways a listing can lose its source. + # + # The snapshot is placed OUTSIDE the origin course, which is where a real one lives (the + # marketplace container). The `:versioned` factory's same-course stand-in cannot be used here: + # deleting the course would try to delete the snapshot too and trip the version's foreign key, + # a collision the production layout makes impossible. + it 'enqueues a rebuild when the whole source course is deleted' do + version = create(:course_assessment_marketplace_listing_version, + listing: listing_without_version, + assessment: create(:assessment, course: create(:course)), + published_at: Time.zone.now, + published_by: listing_without_version.publisher) + listing_without_version.update!(current_version: version) + + expect { course.destroy! }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing_without_version.id, current_user: User.system) + end + + # There is nothing to rebuild FROM: the rebuild duplicates the latest snapshot, and this + # listing has never published one. It stays orphaned, and the admin's only route is deletion. + it 'enqueues nothing for a listing that has never published a version' do + versionless = create(:course_assessment_marketplace_listing, course: course) + + expect { versionless.authoring_assessment.destroy! }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + end + + it 'enqueues nothing when the assessment authors no listing at all' do + expect { assessment.destroy! }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + end + end + end end end diff --git a/spec/services/course/assessment/marketplace/purge_service_spec.rb b/spec/services/course/assessment/marketplace/purge_service_spec.rb new file mode 100644 index 00000000000..db821b4225f --- /dev/null +++ b/spec/services/course/assessment/marketplace/purge_service_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PurgeService, type: :service do + let!(:instance) { Instance.default } + with_tenant(:instance) do + # The `:versioned` trait stands the snapshot up in the origin's own course rather than the shared + # preview container (see the factory) — the rows and the FK graph under test are identical, and + # unrelated specs then do not pay for container provisioning. + let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } + let(:snapshot) { listing.current_version.assessment } + + def orphan! + listing.authoring_assessment.destroy! + listing.reload + end + + # `orphan!` deletes the authoring assessment, which enqueues the automatic `RestoreAuthoringJob` + # rebuild. Under the test env's default :background_thread adapter that job would run CONCURRENTLY + # with the purge under test: it duplicates the snapshot — link-rowing it, so destroying the snapshot + # trips course_assessment_links' foreign key — and un-orphans the listing, so `purgeable?` may be + # false by the time the purge reads it. Enqueue without performing; the rebuild has its own spec. + with_active_job_queue_adapter(:test) do + describe '.purge!' do + context 'when the listing is orphaned with no adoptions' do + before { snapshot && orphan! } + + it 'deletes the listing' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1) + end + + it 'deletes its versions' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::ListingVersion.where(listing_id: listing.id).count }.by(-1) + end + + # Without this the container course would grow forever: nothing else references a snapshot + # once its version row is gone, so there would be no reclaim path. + it 'deletes the container snapshot assessments' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + end + + it 'deletes every snapshot, not only the current one' do + older = create(:assessment, course: snapshot.course) + older_published_at = listing.current_version.published_at - 1.day + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: older, published_at: older_published_at, + published_by: listing.publisher) + + expect { described_class.purge!(listing) }. + to change { Course::Assessment.where(id: [snapshot.id, older.id]).count }.by(-2) + end + + it 'leaves an unrelated listing and its snapshot alone' do + other = create(:course_assessment_marketplace_listing, :versioned) + other_snapshot = other.current_version.assessment + + described_class.purge!(listing) + + expect(other.reload).to be_persisted + expect(other_snapshot.reload).to be_persisted + end + end + + # Unlisted rather than orphaned: the authoring copy is still there, so the source assessment + # outlives the purge and the listing can simply be published again. + context 'when the listing is unlisted with no adoptions' do + before do + snapshot + listing.update!(published: false) + end + + it 'deletes the listing and its snapshots' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + end + + it 'leaves the authoring assessment alone, so the listing can be published again' do + authoring = listing.authoring_assessment + + described_class.purge!(listing) + + expect(authoring.reload).to be_persisted + end + end + + # Publishing is the state that has to be undone first; unlisting is reversible, purging is not. + context 'when the listing is still published' do + it 'raises and deletes nothing' do + snapshot + expect { described_class.purge!(listing) }.to raise_error(ArgumentError) + expect(listing.reload).to be_persisted + expect(snapshot.reload).to be_persisted + end + end + + context 'when the unlisted listing has adoptions' do + before { listing.update!(published: false) } + + it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + + # A purge must never reach into another course's content — the adopter's own copy is not the + # listing's or the container's to delete. + expect(duplicated_assessment.reload).to be_persisted + end + end + + context 'when the orphaned listing has adoptions' do + before { orphan! } + + it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + + expect(duplicated_assessment.reload).to be_persisted + end + end + end + end + end +end From 213421c637445e402bb60ad8bff2234534514b52 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 30 Jul 2026 10:51:08 +0800 Subject: [PATCH 06/28] fix(spec): stop the marketplace specs polluting the shared test DB Nothing rolls back in this suite, so two unscoped `delete_all`s leaked into every later spec file and failed them under some seeds: `User::Email.delete_all` stripped the address off every earlier file's users (surfacing as `SMTP To address may not be blank` in the mailer specs and a nil `user.email` in the external-assessment import specs), and allow-list rows left behind granted `:access_marketplace` to users the ability spec asserts cannot have it. Scope the email cleanup to the domains that file hardcodes, and clear the allow-list tables in the ability spec the way access_list_query_spec.rb already does. --- .../course/assessment/marketplace/allowlist_rule_spec.rb | 9 ++++++++- .../models/course/assessment_marketplace_ability_spec.rb | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb index 4de6e87d06b..3eb3df06c40 100644 --- a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -4,9 +4,16 @@ RSpec.describe Course::Assessment::Marketplace::AllowlistRule, type: :model do let!(:instance) { Instance.default } + # Only this file's own domains: an unscoped `User::Email.delete_all` also strips every earlier spec + # file's users, surfacing as `SMTP To address may not be blank` in the mailer specs. `LOWER() LIKE` + # because the uniqueness index is on `lower(email)` while SQL LIKE is case-sensitive. + HARDCODED_EMAIL_DOMAINS = ['schools.gov.sg', 'other.edu', 'school.edu', 'newdomain.example'].freeze + before do Course::Assessment::Marketplace::AllowlistRule.delete_all - User::Email.delete_all + HARDCODED_EMAIL_DOMAINS.each do |domain| + User::Email.where('LOWER(email) LIKE ?', "%#{domain}").delete_all + end end with_tenant(:instance) do diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 0606e471b9f..8b81eedb78d 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -3,6 +3,14 @@ RSpec.describe Course::Assessment::Marketplace, type: :model do let!(:instance) { Instance.default } + + # Nothing rolls back here, so a leaked `everyone` rule from an earlier spec file would grant + # `:access_marketplace` to the not-allow-listed users below. Same cleanup as access_list_query_spec. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + end + with_tenant(:instance) do let(:course) { create(:course) } let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } From e3a36e7bd44757281b26740f119fe008247409c2 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:47 +0800 Subject: [PATCH 07/28] feat(marketplace): browse, preview and duplicate the served snapshot Browse, listing/question preview and duplication all read the current version's snapshot rather than the live source assessment, and a manager can cut a new version from the assessment page. --- .../marketplace/listings_controller.rb | 26 +- .../marketplace/questions_controller.rb | 8 +- .../marketplace_listings_controller.rb | 35 +- .../assessment/marketplace/duplication_job.rb | 104 +++++- .../marketplace/listings/index.json.jbuilder | 2 +- .../marketplace/listings/show.json.jbuilder | 5 +- client/app/api/course/Marketplace.ts | 8 + .../AssessmentShow/AssessmentShowHeader.tsx | 29 +- .../__test__/AssessmentShowHeader.test.tsx | 67 +++- .../components/DuplicateConfirmation.tsx | 11 +- .../components/PublishToMarketplaceButton.tsx | 36 ++ .../__test__/DuplicationConfirmation.test.tsx | 47 ++- .../PublishToMarketplaceButton.test.tsx | 42 +++ .../ListingPreview/__test__/index.test.tsx | 36 ++ .../course/marketplace/translations.ts | 28 +- client/locales/en.json | 6 +- client/locales/ko.json | 2 +- client/locales/zh.json | 2 +- config/routes.rb | 4 +- .../marketplace/listings_controller_spec.rb | 55 ++- .../marketplace/questions_controller_spec.rb | 73 ++-- .../marketplace_listings_controller_spec.rb | 117 +++++++ .../marketplace/duplication_job_spec.rb | 312 +++++++++++++++++- 23 files changed, 932 insertions(+), 123 deletions(-) diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 77e87b01773..c957b07212c 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -4,18 +4,15 @@ class Course::Assessment::Marketplace::ListingsController < Course::Assessment:: def index ActsAsTenant.without_tenant do - # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on - # the acting-as record. The source course is deliberately NOT preloaded: the MVP exposes no - # attribution, so nothing in the view reaches for it. - # - # `where.not(authoring_assessment_id: nil)` guards an orphaned listing: the authoring copy is - # nullable now that a listing outlives deletion of its origin, and browse reads every field - # off it, so a nil would 500 the whole page rather than hide one row. + # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on the + # acting-as record. Reads go through the current version snapshot, never the authoring copy: the + # marketplace serves what a duplicate would give you (design §4.2). `where.not(current_version_id: + # nil)` guards a published listing with no snapshot, whose nil `current_version` would 500 browse. @listings = Course::Assessment::Marketplace::Listing.published. - where.not(authoring_assessment_id: nil). - includes(authoring_assessment: :lesson_plan_item).to_a + where.not(current_version_id: nil). + includes(current_version: { assessment: :lesson_plan_item }).to_a @adoption_counts = adoption_counts(@listings.map(&:id)) - @question_counts = question_counts(@listings.map(&:authoring_assessment_id)) + @question_counts = question_counts(@listings.map { |listing| listing.current_version.assessment_id }) @destination_tabs = destination_tabs end end @@ -32,11 +29,11 @@ def duplicate def show ActsAsTenant.without_tenant do @listing = Course::Assessment::Marketplace::Listing.published. - includes(:authoring_assessment).find_by(id: params[:id]) + includes(current_version: :assessment).find_by(id: params[:id]) raise CanCan::AccessDenied unless @listing - @assessment = @listing.authoring_assessment - # An orphaned listing has nothing left to preview — see `index`. + # The SNAPSHOT, never the authoring copy (design §4.2). + @assessment = @listing.current_version&.assessment raise CanCan::AccessDenied unless @assessment authorize!(:preview_in_marketplace, @listing) @@ -76,9 +73,8 @@ def destination_tabs def authorized_listings listings = ActsAsTenant.without_tenant do - # Orphaned listings excluded for the reason `index` gives — there is no content to copy. Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]). - where.not(authoring_assessment_id: nil).includes(:authoring_assessment) + includes(current_version: :assessment) end raise CanCan::AccessDenied if listings.empty? diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb index ccbba11729b..22263efdff5 100644 --- a/app/controllers/course/assessment/marketplace/questions_controller.rb +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -4,12 +4,12 @@ class Course::Assessment::Marketplace::QuestionsController < Course::Assessment: def show ActsAsTenant.without_tenant do - listing = Course::Assessment::Marketplace::Listing.published.includes(:authoring_assessment). - find_by(id: params[:listing_id]) + listing = Course::Assessment::Marketplace::Listing.published. + includes(current_version: :assessment).find_by(id: params[:listing_id]) raise CanCan::AccessDenied unless listing - @assessment = listing.authoring_assessment - # An orphaned listing has nothing left to preview — see ListingsController#index. + # The SNAPSHOT, never the authoring copy (design §4.2). + @assessment = listing.current_version&.assessment raise CanCan::AccessDenied unless @assessment authorize!(:preview_in_marketplace, listing) diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb index 1cf3dd4b557..14a25defb1a 100644 --- a/app/controllers/course/assessment/marketplace_listings_controller.rb +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -2,18 +2,31 @@ class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller before_action :authorize_publish_to_marketplace! + # A published version of an existing listing is not a source assessment. Refused server-side and + # not only by withholding the button: the listing this would create has its source assessment + # frozen inside the container, so it could never be edited nor cut a further version. + SNAPSHOT_REJECTION = 'This is a published version of an existing listing, not a source assessment.' + def create - listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(authoring_assessment: @assessment) - now = Time.zone.now - listing.published = true - listing.first_published_at ||= now - listing.last_published_at = now - listing.publisher ||= current_user - if listing.save - render json: { published: true }, status: :ok - else - render json: { errors: listing.errors.full_messages }, status: :unprocessable_content - end + return render json: { errors: [SNAPSHOT_REJECTION] }, status: :unprocessable_content if + @assessment.marketplace_snapshot? + + listing = Course::Assessment::Marketplace::PublishService.publish(@assessment, current_user) + render json: { published: listing.published }, status: :ok + rescue ActiveRecord::RecordInvalid => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content + end + + # Cuts v(n+1) from the authoring copy. Deliberately separate from `create`: re-listing an unlisted + # assessment reactivates the row but must NOT silently republish changed content (design §5.2). + def publish_version + listing = @assessment.marketplace_listing + return render json: { errors: ['Not listed on the marketplace.'] }, status: :unprocessable_content if listing.nil? + + version = Course::Assessment::Marketplace::PublishService.publish_new_version(listing, current_user) + render json: { published_at: version.published_at }, status: :ok + rescue ArgumentError => e + render json: { errors: [e.message] }, status: :unprocessable_content end def destroy diff --git a/app/jobs/course/assessment/marketplace/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb index 1fdf02cd63c..dc521ca2f03 100644 --- a/app/jobs/course/assessment/marketplace/duplication_job.rb +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -5,31 +5,41 @@ class Course::Assessment::Marketplace::DuplicationJob < ApplicationJob queue_as :duplication + # Mirrors `validates :title, length: { maximum: 255 }` on Course::LessonPlan::Item, which is where + # an assessment's title actually lives. + TITLE_LIMIT = 255 + protected def perform_tracked(listing_ids, destination_course, destination_tab_id, options = {}) current_user = options[:current_user] ActsAsTenant.without_tenant do listings = Course::Assessment::Marketplace::Listing.published.where(id: listing_ids) - listings.each do |listing| + copies = listings.map do |listing| copy = duplicate_listing(listing, destination_course, current_user) reparent_into_tab(copy, destination_course, destination_tab_id) + resolve_title_collision(copy, listing, destination_course) record_adoption(listing, destination_course, copy, current_user) + copy end - redirect_to course_assessments_url(destination_course, - category: destination_course.assessment_categories.first.id, - tab: destination_tab_id, - host: destination_course.instance.host) + landing_url = landing_url_for(copies, destination_course) + redirect_to landing_url if landing_url end end private def duplicate_listing(listing, destination_course, current_user) - source = listing.authoring_assessment - Course::Duplication::ObjectDuplicationService.duplicate_objects( + # The SNAPSHOT (design §4.2). `source.course` is therefore the hidden container course, which is + # exactly what `duplicate_objects` needs as its source course. + source = listing.current_version.assessment + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( source.course, destination_course, source, current_user: current_user ) + # An adopted copy is a standalone assessment, not a link-sibling of the container snapshot and + # of every other adopter's copy. See Course::Assessment#detach_from_link_tree!. + copy.detach_from_link_tree! + copy end def reparent_into_tab(copy, destination_course, destination_tab_id) @@ -42,11 +52,91 @@ def reparent_into_tab(copy, destination_course, destination_tab_id) copy.save! end + # Renames an imported copy whose title is already taken in the destination course. + # + # Fires on every import, not only on re-import of the same listing: a copy landing on top of an + # unrelated assessment of the same name collides just as badly, and previously landed silently. + # + # Escalates only as far as it has to: + # "Lab 3" -> "Lab 3 [12 Jun 2026]" -> "Lab 3 [12 Jun 2026] (2)" -> (3) ... + # + # The date is the content's vintage, read from the same `ListingVersion#published_at` the adopter + # update banner uses, so the two can never disagree; `%d %b %Y` matches the frontend's + # `formatLongDate`. It renders in the app's `Time.zone` while the banner renders in the browser's, so + # near midnight they can name adjacent days — accepted, the stamp only tells two rows apart. + # + # The base title comes from the immutable container snapshot, never from the previous copy, so + # repeated re-imports cannot compound the suffix. + # + # @param [Course::Assessment] copy + # @param [Course::Assessment::Marketplace::Listing] listing + # @param [Course] destination_course + # @return [void] + def resolve_title_collision(copy, listing, destination_course) + taken = Course::Assessment.titles_in_course(destination_course, except_id: copy.id) + base = copy.title + return if taken.exclude?(base.downcase) + + published_at = ActsAsTenant.without_tenant { listing.current_version&.published_at } + # A listing with no recorded vintage has nothing to name, so it goes straight to the counter — + # stamping an empty "[]" would be worse than the collision it is trying to resolve. + dated = published_at ? "#{base} [#{published_at.strftime('%d %b %Y')}]" : base + candidate = truncate_to_limit(dated, base) + + suffix_number = 2 + while taken.include?(candidate.downcase) + candidate = truncate_to_limit("#{dated} (#{suffix_number})", base) + suffix_number += 1 + end + + copy.title = candidate + copy.save! + end + + # Truncate the BASE, never the suffix: an over-long title fails validation on save, and a suffix + # cut in half no longer distinguishes anything. + # + # @param [String] candidate + # @param [String] base + # @return [String] + def truncate_to_limit(candidate, base) + return candidate if candidate.length <= TITLE_LIMIT + + suffix = candidate.delete_prefix(base) + base.truncate(TITLE_LIMIT - suffix.length) + suffix + end + + # Where the completion toast's link sends the manager. + # + # A single copy links to the copy: after "Import latest version" the next move is to look at what + # landed, and the tab index cannot tell the fresh copy from the one it supersedes. A bulk + # duplication has no single destination, so it keeps the index — but sourced from the copy's OWN + # tab, not `assessment_categories.first`, which is the destination tab's category only by accident. + # + # Reading the tab off the copy rather than trusting `destination_tab_id` also covers the case where + # `reparent_into_tab` declined to move it (an unknown or zero tab id): the link still points at + # wherever the copies really are. + # + # @param [Array] copies + # @param [Course] destination_course + # @return [String, nil] nil when every listing was filtered out by `.published`, in which case + # nothing landed and there is nowhere to link to. + def landing_url_for(copies, destination_course) + return nil if copies.empty? + + host = destination_course.instance.host + return course_assessment_url(destination_course, copies.first, host: host) if copies.one? + + tab = copies.first.tab + course_assessments_url(destination_course, category: tab.category_id, tab: tab.id, host: host) + end + def record_adoption(listing, destination_course, copy, current_user) Course::Assessment::Marketplace::Adoption.create!( listing: listing, destination_course: destination_course, duplicated_assessment: copy, + adopted_version_at: listing.current_version.published_at, creator: current_user, updater: current_user ) diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder index 5b67c876813..39c64621555 100644 --- a/app/views/course/assessment/marketplace/listings/index.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.canAccess true json.listings @listings do |listing| - assessment = listing.authoring_assessment + assessment = listing.current_version.assessment json.id listing.id json.assessmentId assessment.id json.title assessment.title diff --git a/app/views/course/assessment/marketplace/listings/show.json.jbuilder b/app/views/course/assessment/marketplace/listings/show.json.jbuilder index 92d6b4f7305..06cc7bddcca 100644 --- a/app/views/course/assessment/marketplace/listings/show.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/show.json.jbuilder @@ -1,5 +1,8 @@ # frozen_string_literal: true -json.id @assessment.id +# The LISTING's id, matching `index.json.jbuilder` — everything below is the snapshot assessment's +# content, but the resource this payload identifies is the listing. The duplicate dialog posts this +# id back as a `listing_ids` entry, so the snapshot assessment's id here 403s the duplicate. +json.id @listing.id json.title @assessment.title json.description format_ckeditor_rich_text(@assessment.description) diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 8f1f4bf7ea8..a5011259158 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -21,6 +21,14 @@ export default class MarketplaceAPI extends BaseCourseAPI { ); } + publishNewVersion( + assessmentId: number, + ): Promise> { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing/versions`, + ); + } + index(): Promise< AxiosResponse<{ listings: MarketplaceListing[]; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx index 4a9f30ad51e..d189d3fa054 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx @@ -18,6 +18,7 @@ import marketplaceTranslations from 'course/marketplace/translations'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; import { PromptText } from 'lib/components/core/dialogs/Prompt'; import Link from 'lib/components/core/Link'; +import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; @@ -68,7 +69,11 @@ const AssessmentShowHeader = ( }; return ( - <> + // `shrink-0` keeps each button at its natural width so labels never wrap — + // a wrapped label would render as a tall pill under the theme's + // `rounded-full` buttons. `flex-wrap` lets whole buttons drop to a new row + // when the header is narrow. +
{assessment.deleteUrl && ( - - {t(translations.deletingThisAssessment)} - {assessment.isPublishedToMarketplace && ( - - {t(marketplaceTranslations.deleteWarning)} - - )} - + {t(translations.deletingThisAssessment)} {assessment.title} + {assessment.isPublishedToMarketplace && ( + + {t(marketplaceTranslations.deleteWarning, { + mailto: (chunk: string): JSX.Element => ( + + {chunk} + + ), + })} + + )} {t(translations.deleteAssessmentWarning)} )} @@ -182,7 +191,7 @@ const AssessmentShowHeader = ( )} - +
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx index e2779330448..e1d9ef7bf64 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx @@ -1,4 +1,6 @@ -import { fireEvent, render } from 'test-utils'; +import { fireEvent, render, within } from 'test-utils'; + +import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants'; import AssessmentShowHeader from '../AssessmentShowHeader'; @@ -23,10 +25,11 @@ const baseAssessment = { // Test the conditional in the delete Prompt whose // message contains this phrase, rendered only when `isPublishedToMarketplace`. -const MARKETPLACE_WARNING = /removes it from the marketplace/i; +const MARKETPLACE_WARNING = /keeps serving its last published version/i; +const DELETE_ASSESSMENT_LABEL = 'Delete Assessment'; describe('', () => { - it('warns that deletion removes the marketplace listing when the assessment is listed', async () => { + it('explains that the marketplace listing survives deletion when the assessment is listed', async () => { const page = render( ', () => { ); // First query awaits the i18n LoadingIndicator; subsequent getBy* are sync. - fireEvent.click(await page.findByLabelText('Delete Assessment')); // opens the delete Prompt + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); // opens the delete Prompt expect(page.getByText(MARKETPLACE_WARNING)).toBeVisible(); }); + it('names the assessment right after the intro line, before the marketplace explanation', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); + + const content = page.getByRole('dialog').textContent ?? ''; + const positions = [ + 'You are about to delete the following assessment:', + baseAssessment.title, + 'keeps serving its last published version', + 'This action cannot be undone!', + ].map((phrase) => content.indexOf(phrase)); + + // -1 would make the ascending check vacuously true, so require every phrase. + expect(positions).not.toContain(-1); + expect(positions).toEqual([...positions].sort((a, b) => a - b)); + }); + + it('links to support so the listing can be unlisted', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); + + expect(page.getByRole('link', { name: /contact us/i })).toHaveAttribute( + 'href', + `mailto:${SUPPORT_EMAIL}`, + ); + }); + + // Internal vocabulary must not leak into the instructor-facing warning. + it('calls the lost object the source assessment', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); + + const dialog = await page.findByRole('dialog'); + expect(within(dialog).getByText(/source assessment/)).toBeVisible(); + expect( + within(dialog).queryByText(/authoring\s+copy/), + ).not.toBeInTheDocument(); + }); + it('shows no marketplace warning when the assessment is not listed', async () => { const page = render( ', () => { />, ); - fireEvent.click(await page.findByLabelText('Delete Assessment')); // delete Prompt still opens + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); // delete Prompt still opens expect(page.queryByText(MARKETPLACE_WARNING)).not.toBeInTheDocument(); }); }); diff --git a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx index 06ec2ea5c5a..fdb37b49950 100644 --- a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -77,12 +77,11 @@ const DuplicateConfirmation = ({ <> {t(translations.duplicateCompleted, { n: listings.length })} {redirectUrl && ( - <> - {' '} - - {t(translations.viewDuplicatedAssessment)} - - + + {t(translations.viewDuplicatedAssessment, { + n: listings.length, + })} + )} , ); diff --git a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx index f016ac17033..856911b5fda 100644 --- a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -23,6 +23,7 @@ const PublishToMarketplaceButton = ({ }: Props): JSX.Element | null => { const { t } = useTranslation(); const [open, setOpen] = useState(false); + const [versionOpen, setVersionOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const listed = assessment.isPublishedToMarketplace; @@ -46,8 +47,31 @@ const PublishToMarketplaceButton = ({ } }; + const confirmNewVersion = async (): Promise => { + setSubmitting(true); + try { + await CourseAPI.marketplace.publishNewVersion(assessment.id); + toast.success(t(translations.newVersionPublished)); + setVersionOpen(false); + } catch { + toast.error(t(translations.newVersionFailed)); + } finally { + setSubmitting(false); + } + }; + return ( <> + {listed && ( + + )} + + ) : ( + + {t(translations.marketplaceUpdateBlocked)} + + )} + + + setConfirming(false)} + open={confirming} + primaryColor="primary" + primaryLabel={t(translations.marketplaceUpdateInPlace)} + title={t(translations.marketplaceUpdateConfirmTitle)} + > + + {t(translations.marketplaceUpdateConfirmBody, { + latest: latestDate, + })} + + + {update.testSubmissionCount > 0 && ( + + {t(translations.marketplaceUpdateConfirmDeletion, { + count: update.testSubmissionCount, + })} + + )} + + + ); +}; + +export default MarketplaceUpdateBanner; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx new file mode 100644 index 00000000000..31937d7c3bd --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx @@ -0,0 +1,83 @@ +import { render, RenderResult } from 'test-utils'; +import { AssessmentData } from 'types/course/assessment/assessments'; + +import AssessmentShowPage from '../AssessmentShowPage'; + +// Minimal AssessmentData: enough for the page to mount. Everything optional is left out so the +// assertions below can only be about the marketplace chip. +const baseAssessment = { + id: 1, + title: 'Sample Assessment', + tabTitle: 'Assessments: Default', + tabUrl: '/courses/1/assessments', + description: '', + autograded: false, + startAt: { isFixed: false, effectiveTime: null, referenceTime: null }, + hasAttempts: false, + status: 'open', + actionButtonUrl: null, + permissions: { + canAttempt: true, + canManage: true, + canObserve: false, + canInviteToKoditsu: false, + canPublishToMarketplace: false, + }, + isPublishedToMarketplace: false, + marketplaceListingUrl: '/courses/1/assessments/1/marketplace_listing', + marketplaceUpdate: null, + requirements: [], + indexUrl: '/courses/1/assessments', + isStudent: false, +} as unknown as AssessmentData; + +const renderWith = ( + marketplaceVersion?: AssessmentData['marketplaceVersion'], +): RenderResult => + render( + , + ); + +// '2026-07-24T07:04:00Z' rendered in Asia/Singapore (UTC+8), as in MarketplaceVersionChip's own test. +const PUBLISHED_AT_LABEL = '24 Jul 2026, 3:04pm'; + +describe('', () => { + // Every snapshot in the container carries the origin's title verbatim and shares one tab, so the + // page has to say which one this is — otherwise opening a container row loses the identity the + // index row showed. + it('dates a container snapshot and marks it live', async () => { + const page = renderWith({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: true, + listed: true, + }); + + expect(await page.findByText(PUBLISHED_AT_LABEL)).toBeVisible(); + expect(page.getByText('Live')).toBeVisible(); + }); + + // The working copy is not a version at all — mistaking it for one would read as though the + // marketplace serves whatever an admin is midway through editing. + it("labels the listing's working copy as the source assessment", async () => { + const page = renderWith({ + listingId: 7, + publishedAt: null, + source: 'MP Allowlist Source Course', + latest: false, + listed: true, + }); + + expect(await page.findByText('Source Assessment')).toBeVisible(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + }); + + it('shows no marketplace chip outside the container', async () => { + const page = renderWith(undefined); + + expect(await page.findByText(baseAssessment.title)).toBeVisible(); + expect(page.queryByText(/2026/)).not.toBeInTheDocument(); + expect(page.queryByText('Source Assessment')).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceUpdateBanner.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceUpdateBanner.test.tsx new file mode 100644 index 00000000000..936545a3549 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceUpdateBanner.test.tsx @@ -0,0 +1,321 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import GlobalAPI from 'api'; +import CourseAPI from 'api/course'; + +import MarketplaceUpdateBanner from '../MarketplaceUpdateBanner'; + +const mockUpdateToast = { + success: jest.fn(), + error: jest.fn(), +}; + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { success: jest.fn(), error: jest.fn() }, + loadingToast: jest.fn(() => mockUpdateToast), +})); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +// pollJob polls the *jobs* endpoint, which lives on a different axios client to the marketplace API. +const jobsMock = createMockAdapter(GlobalAPI.jobs.client); + +beforeEach(() => { + mock.reset(); + jobsMock.reset(); + jest.clearAllMocks(); +}); + +// Students have submitted work, so this copy can never be replaced in place. +const update = { + adoptedVersionAt: '2026-06-12T00:00:00Z', + latestVersionAt: '2026-07-24T00:00:00Z', + canUpdateInPlace: false, + testSubmissionCount: 0, +}; + +// No student has touched this copy, so the marketplace's newer content can replace it where it sits. +const updatableInPlace = { + ...update, + canUpdateInPlace: true, +}; + +// Two cuts on the same calendar day: the pair must escalate to include the time, or the banner +// would tell the manager their copy is from the same day it was superseded. +const sameDayUpdate = { + ...update, + adoptedVersionAt: '2026-07-24T01:00:00Z', + latestVersionAt: '2026-07-24T07:04:00Z', +}; + +const APPLY_URL = `/courses/${global.courseId}/assessments/5/marketplace_adoption/apply_latest_version`; +const JOB_URL = '/jobs/9'; +const REDIRECT_URL = `/courses/${global.courseId}/assessments/53`; +// What the apply endpoint answers with: the job is merely enqueued, and `jobUrl` is where its +// progress is reported. +const enqueued = { status: 'submitted', jobUrl: JOB_URL }; +const UPDATE = 'Update this assessment'; + +// Version numbers are not a user-facing concept: the manager who copied this assessment never saw +// "v1". The banner therefore dates both content vintages instead of numbering them. +// formatLongDate('2026-07-24T00:00:00Z') under TZ=Asia/Singapore → '24 Jul 2026'. +it('dates both content vintages without version numbers, sync or behind', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(alert.textContent).toContain( + 'This assessment was updated in the marketplace on 24 Jul 2026. Your copy is from 12 Jun 2026.', + ); + expect(alert.textContent).not.toMatch(/\bv\d/); + expect(alert.textContent).not.toMatch(/sync/i); + expect(alert.textContent).not.toMatch(/behind/i); +}); + +it('escalates to the time when both vintages fall on one day', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(alert.textContent).toContain( + 'This assessment was updated in the marketplace on 24 Jul 2026, 3:04pm. Your copy is from 24 Jul 2026, 9:00am.', + ); +}); + +// The notice is a statement of fact about the copy, not a notification, so nothing may silence it. +// MUI renders Alert's close × whenever `onClose` is passed, so counting the buttons is what keeps +// the banner un-closeable — the update is the only thing it may ever offer. +it('renders the update as its only button, with nothing to close it', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(within(alert).getAllByRole('button')).toHaveLength(1); + expect( + within(alert).getByRole('button', { name: UPDATE }), + ).toBeInTheDocument(); +}); + +// Replacing the content would destroy the students' work, so there is nothing safe to offer. An +// action-less banner is only honest if it says why — otherwise the manager hunts for a button. +it('explains why it cannot update when students have submitted work', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(within(alert).queryAllByRole('button')).toHaveLength(0); + expect(alert.textContent).toContain('can no longer be updated automatically'); + expect(alert.textContent).toContain('students have already submitted work'); + expect(alert.textContent).toMatch(/edits of your own/i); + expect(alert.textContent).toMatch(/import this assessment .* again/i); +}); + +it('offers to update in place when no student has submitted work', async () => { + const page = render( + , + ); + + expect(await page.findByRole('button', { name: UPDATE })).toBeInTheDocument(); + expect( + page.queryByText(/can no longer be updated automatically/), + ).not.toBeInTheDocument(); +}); + +it('names the test submissions the update will delete', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + + const dialog = await page.findByRole('dialog'); + expect(dialog.textContent).toContain('2 test submissions'); + expect(dialog.textContent).toContain('replaces'); +}); + +it('omits the deletion warning when there is nothing to delete', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + + const dialog = await page.findByRole('dialog'); + expect(dialog.textContent).not.toMatch(/test submission/i); +}); + +// The manager is about to overwrite their content, so the prompt has to name WHICH version it is +// about to bring in — the same vintage the banner is reporting. +it('names the incoming version in the confirmation', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + + const dialog = await page.findByRole('dialog'); + expect(dialog.textContent).toContain('published on 24 Jul 2026'); +}); + +it('posts the in-place update on confirm', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock.onGet(JOB_URL).reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: new RegExp(UPDATE) }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(mock.history.post[0].url).toBe(APPLY_URL); + await waitFor(() => expect(mockUpdateToast.error).toHaveBeenCalled(), { + timeout: 6000, + }); +}); + +// `canUpdateInPlace` is advisory: the endpoint re-checks for student work and answers 422 if a +// student has submitted since the page loaded. The request never reaches pollJob, so nothing else +// can unlock the prompt or retract the loading toast. +it('reports a refused update and unlocks the prompt', async () => { + mock + .onPost(APPLY_URL) + .reply(422, { errors: ['Students have submitted work.'] }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + const confirm = within(dialog).getByRole('button', { + name: new RegExp(UPDATE), + }); + fireEvent.click(confirm); + + await waitFor(() => + expect(mockUpdateToast.error).toHaveBeenCalledWith( + 'Could not update this assessment.', + ), + ); + expect(page.getByRole('dialog')).toBeInTheDocument(); + await waitFor(() => expect(confirm).toBeEnabled()); +}); + +// The one thing that retires the banner: the copy has stopped being behind. The page still holds +// the pre-update payload, so the banner is the only thing that can notice. +it('reports completion once the in-place update job finishes', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock + .onGet(JOB_URL) + .reply(200, { status: 'completed', redirectUrl: REDIRECT_URL }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: new RegExp(UPDATE) }), + ); + + await waitFor(() => expect(mockUpdateToast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + await waitFor(() => + expect(page.queryByRole('alert')).not.toBeInTheDocument(), + ); +}, 10000); + +it('keeps the update locked while the job is still running', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock + .onGet(JOB_URL) + .replyOnce(200, { status: 'submitted' }) + .onGet(JOB_URL) + .reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + const confirm = within(dialog).getByRole('button', { + name: new RegExp(UPDATE), + }); + fireEvent.click(confirm); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + // The job has not reported back, so the dialog must stay open and un-resubmittable. + expect(confirm).toBeDisabled(); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + fireEvent.click(confirm); + expect(mock.history.post).toHaveLength(1); + + await waitFor(() => expect(mockUpdateToast.error).toHaveBeenCalled(), { + timeout: 6000, + }); +}, 10000); + +it('reports a failed job and unlocks the dialog for a retry', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock.onGet(JOB_URL).reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + const confirm = within(dialog).getByRole('button', { + name: new RegExp(UPDATE), + }); + fireEvent.click(confirm); + + await waitFor(() => expect(mockUpdateToast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(mockUpdateToast.error).toHaveBeenCalledWith( + 'Could not update this assessment.', + ); + expect(page.getByRole('dialog')).toBeInTheDocument(); + // The banner is still there too: nothing was updated, so it is still telling the truth. Queried + // by text rather than by role — the open dialog `aria-hidden`s the rest of the body, so its + // `alert` role is unreachable while the retry prompt is up. + expect( + page.getByText(/This assessment was updated in the marketplace/), + ).toBeInTheDocument(); + await waitFor(() => expect(confirm).toBeEnabled()); +}, 10000); + +it('renders nothing when there is no update', async () => { + // The sentinel is what makes this assertion mean anything: `test-utils` mounts a translations + // Suspense, so the alert is absent on the first tick regardless. Awaiting a sibling proves the + // tree finished mounting; only then is the alert's absence evidence the component returned null. + const page = render( + <> + sentinel + + , + ); + + expect(await page.findByText('sentinel')).toBeInTheDocument(); + expect(page.queryByRole('alert')).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/versionVintage.test.ts b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/versionVintage.test.ts new file mode 100644 index 00000000000..db97056d49a --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/versionVintage.test.ts @@ -0,0 +1,41 @@ +import { formatVintagePair } from '../versionVintage'; + +// Tests run under TZ=Asia/Singapore, so a UTC instant renders +8h. +describe('formatVintagePair', () => { + it('renders dates only when the two vintages fall on different days', () => { + expect( + formatVintagePair('2026-06-12T00:00:00Z', '2026-07-24T00:00:00Z'), + ).toEqual({ adopted: '12 Jun 2026', latest: '24 Jul 2026' }); + }); + + // A listing republished twice in one day would otherwise render "updated on 24 Jul 2026, your + // copy is from 24 Jul 2026" — self-contradicting, and the adopter cannot resolve it. + it('escalates BOTH vintages to include the time when they share a calendar day', () => { + expect( + formatVintagePair('2026-07-24T01:00:00Z', '2026-07-24T07:04:00Z'), + ).toEqual({ + adopted: '24 Jul 2026, 9:00am', + latest: '24 Jul 2026, 3:04pm', + }); + }); + + // Same calendar day is judged in the VIEWER's zone, which is what they read on screen. These two + // instants are different UTC days but the same Singapore day. + it('judges the shared day in the viewer timezone, not UTC', () => { + expect( + formatVintagePair('2026-07-23T17:00:00Z', '2026-07-24T02:00:00Z'), + ).toEqual({ + adopted: '24 Jul 2026, 1:00am', + latest: '24 Jul 2026, 10:00am', + }); + }); + + it('escalates when the two vintages are the identical instant', () => { + expect( + formatVintagePair('2026-07-24T07:04:00Z', '2026-07-24T07:04:00Z'), + ).toEqual({ + adopted: '24 Jul 2026, 3:04pm', + latest: '24 Jul 2026, 3:04pm', + }); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/versionVintage.ts b/client/app/bundles/course/assessment/pages/AssessmentShow/versionVintage.ts new file mode 100644 index 00000000000..c24a25c2a6e --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/versionVintage.ts @@ -0,0 +1,24 @@ +import moment, { formatLongDate, formatLongDateTime } from 'lib/moment'; + +/** + * Formats an adopted vintage and the served vintage as a pair. + * + * A version is identified by when it was published, and adopters read that as a date — a time is + * false precision here. But a listing republished twice in one day would render "updated on 24 Jul + * 2026. Your copy is from 24 Jul 2026.", which the adopter cannot resolve. So precision escalates to + * include the time exactly when the two vintages would otherwise be indistinguishable. + * + * Both sides escalate together — one dated and one timestamped would read as a different kind of + * thing rather than as two points on one scale. + * + * The shared-day test is made in the viewer's timezone, because that is the rendering they compare. + */ +export const formatVintagePair = ( + adopted: string, + latest: string, +): { adopted: string; latest: string } => { + const sameDay = moment(adopted).isSame(moment(latest), 'day'); + const format = sameDay ? formatLongDateTime : formatLongDate; + + return { adopted: format(adopted), latest: format(latest) }; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/MarketplaceVersionChip.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/MarketplaceVersionChip.tsx new file mode 100644 index 00000000000..1e9346cdec7 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/MarketplaceVersionChip.tsx @@ -0,0 +1,108 @@ +import { FC } from 'react'; +import { Chip, Tooltip } from '@mui/material'; +import { MarketplaceVersionData } from 'types/course/assessment/assessments'; + +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +import translations from '../../translations'; + +interface MarketplaceVersionChipProps { + for: MarketplaceVersionData; +} + +/** + * Tells apart the assessments in the marketplace container course, which all sit in one tab under + * their original titles: immutable published snapshots, chipped with their publication date, and the + * listing's editable working copy, chipped "Source Assessment". View-only — nothing here is ever + * retitled, because an adopter's duplicated copy reads that title. + */ +const MarketplaceVersionChip: FC = (props) => { + const { for: marketplaceVersion } = props; + const { t } = useTranslation(); + const publishedAt = marketplaceVersion.publishedAt; + + // A null vintage means the working copy, which is not a version at all — hence a different label + // and a different colour, so an admin never mistakes it for something the marketplace serves. + const isAuthoring = publishedAt === null; + + // Two different facts. `latest` is the newest cut; `listed` is whether the listing is on the + // marketplace. Only their conjunction means "this is what an adopter gets", and only that earns + // the strong label — so Live stands in for Latest rather than sitting beside it. + const isLive = marketplaceVersion.latest && marketplaceVersion.listed; + + const hint = ((): string => { + if (isAuthoring) { + return marketplaceVersion.source + ? t(translations.marketplaceAuthoringHintWithSource, { + listingId: marketplaceVersion.listingId, + source: marketplaceVersion.source, + }) + : t(translations.marketplaceAuthoringHint, { + listingId: marketplaceVersion.listingId, + }); + } + + return marketplaceVersion.source + ? t(translations.marketplaceVersionHintWithSource, { + listingId: marketplaceVersion.listingId, + source: marketplaceVersion.source, + }) + : t(translations.marketplaceVersionHint, { + listingId: marketplaceVersion.listingId, + }); + })(); + + // Date AND time: one container tab holds every snapshot of every listing, so same-day siblings + // sit next to each other and the time is the only thing separating them. + const label = isAuthoring + ? t(translations.marketplaceAuthoring) + : t(translations.marketplaceVersion, { + version: formatLongDateTime(publishedAt), + }); + + return ( +
+ + + + + {/* A SECOND chip rather than recolouring the date, because the admin needs the date and the + status at once — and because colour alone is not a signal everyone can read. Filled for + Live so it is the thing the eye lands on among outlined neighbours; outlined for Latest, + which is the same fact one degree weaker. */} + {!isAuthoring && marketplaceVersion.latest && ( + + + + )} +
+ ); +}; + +export default MarketplaceVersionChip; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/MarketplaceVersionChip.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/MarketplaceVersionChip.test.tsx new file mode 100644 index 00000000000..b0e9c2722cf --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/MarketplaceVersionChip.test.tsx @@ -0,0 +1,140 @@ +import userEvent from '@testing-library/user-event'; +import { render } from 'test-utils'; +import { MarketplaceVersionData } from 'types/course/assessment/assessments'; + +import MarketplaceVersionChip from '../MarketplaceVersionChip'; + +// '2026-07-24T07:04:00Z' rendered in Asia/Singapore (UTC+8). +const PUBLISHED_AT_LABEL = '24 Jul 2026, 3:04pm'; + +const snapshot = ( + overrides: Partial = {}, +): MarketplaceVersionData => ({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: false, + listed: true, + ...overrides, +}); + +describe('', () => { + // One container tab holds every snapshot of every listing under identical titles, so siblings ARE + // side by side here — the time is what tells two same-day cuts apart. + it('labels a snapshot with its publish date and time', async () => { + const page = render(); + + expect(await page.findByText(PUBLISHED_AT_LABEL)).toBeInTheDocument(); + }); + + it('labels the working copy as the source assessment rather than a date', async () => { + const page = render( + , + ); + + expect(await page.findByText('Source Assessment')).toBeInTheDocument(); + expect(page.queryByText(/2026/)).not.toBeInTheDocument(); + }); + + it('marks the newest version of a published listing as Live, alongside its date', async () => { + const page = render( + , + ); + + expect(await page.findByText('Live')).toBeInTheDocument(); + // The date is not replaced by the status — an admin needs both. + expect(page.getByText(PUBLISHED_AT_LABEL)).toBeInTheDocument(); + // Live and Latest are mutually exclusive: Live is the stronger of the two and stands in for it. + expect(page.queryByText('Latest')).not.toBeInTheDocument(); + }); + + // An unlisted listing still HAS a newest version — it is what an admin re-publishing acts on — but + // nothing is being served, so it must not read Live. + it('marks the newest version of an unlisted listing as Latest, not Live', async () => { + const page = render( + , + ); + + expect(await page.findByText('Latest')).toBeInTheDocument(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + }); + + it('marks a superseded snapshot neither Live nor Latest', async () => { + const page = render( + , + ); + + expect(await page.findByText(PUBLISHED_AT_LABEL)).toBeInTheDocument(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + expect(page.queryByText('Latest')).not.toBeInTheDocument(); + }); + + // Unreachable today — the backend hardcodes `latest: false` for the working copy — but + // constructible here, and the two classifiers must not be able to disagree: the Version filter in + // AssessmentsTable already treats a null `publishedAt` as "Source Assessment" regardless of + // `latest`, so this chip must never render Live or Latest alongside it. + it('never marks the working copy Live or Latest, even if `latest` were true', async () => { + const page = render( + , + ); + + expect(await page.findByText('Source Assessment')).toBeInTheDocument(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + expect(page.queryByText('Latest')).not.toBeInTheDocument(); + }); + + it('identifies the listing by a stable id rather than an ordinal', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.hover(await page.findByText(PUBLISHED_AT_LABEL)); + + const tooltip = await page.findByRole('tooltip'); + expect(tooltip).toHaveTextContent( + 'Listing ID 12 · from MP Allowlist Source Course', + ); + // "#12" reads as a position in a list, which is what made an admin expect it to renumber when a + // neighbouring listing was deleted. It is a primary key and never moves. + expect(tooltip).not.toHaveTextContent('#12'); + }); + + it('names the listing alone when the source course was never recorded', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.hover(await page.findByText(PUBLISHED_AT_LABEL)); + + const tooltip = await page.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Listing ID 12'); + expect(tooltip).not.toHaveTextContent('from'); + }); + + it('says the working copy is not a published version', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.hover(await page.findByText('Source Assessment')); + + expect(await page.findByRole('tooltip')).toHaveTextContent( + 'Listing ID 12 · editable working copy, not a published version', + ); + }); +}); diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 8e225e47cbc..4143fd2a042 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -1,6 +1,47 @@ import { defineMessages } from 'react-intl'; const translations = defineMessages({ + marketplaceUpdateAvailable: { + id: 'course.assessment.marketplaceUpdateAvailable', + defaultMessage: + 'This assessment was updated in the marketplace on {latest}. Your copy is from {adopted}.', + }, + marketplaceUpdateInPlace: { + id: 'course.assessment.marketplaceUpdateInPlace', + defaultMessage: 'Update this assessment', + }, + marketplaceUpdateBlocked: { + id: 'course.assessment.marketplaceUpdateBlocked', + defaultMessage: + 'This assessment can no longer be updated automatically: students have already submitted work for it, and it may carry edits of your own. Replacing its content would discard both. To use the new version, import this assessment from the marketplace again.', + }, + marketplaceUpdateConfirmTitle: { + id: 'course.assessment.marketplaceUpdateConfirmTitle', + defaultMessage: 'Update this assessment?', + }, + marketplaceUpdateConfirmBody: { + id: 'course.assessment.marketplaceUpdateConfirmBody', + defaultMessage: + "This replaces this assessment's questions and materials with the version published on {latest}. It keeps its place in your course, its deadlines, and whether it is published.", + }, + marketplaceUpdateConfirmDeletion: { + id: 'course.assessment.marketplaceUpdateConfirmDeletion', + defaultMessage: + '{count, plural, one {# test submission} other {# test submissions}} on this assessment will be deleted. No student has submitted work for it.', + }, + marketplaceUpdateStarted: { + id: 'course.assessment.marketplaceUpdateStarted', + defaultMessage: 'Updating this assessment…', + }, + marketplaceUpdateCompleted: { + id: 'course.assessment.marketplaceUpdateCompleted', + defaultMessage: + 'Assessment updated to the latest version. Refresh to see the latest version.', + }, + marketplaceUpdateFailed: { + id: 'course.assessment.marketplaceUpdateFailed', + defaultMessage: 'Could not update this assessment.', + }, updateAssessment: { id: 'course.assessment.edit.update', defaultMessage: 'Save', @@ -160,6 +201,54 @@ const translations = defineMessages({ id: 'course.assessments.index.seeAllRequirements', defaultMessage: 'See all requirements', }, + marketplaceVersion: { + id: 'course.assessments.index.marketplaceVersion', + defaultMessage: '{version}', + }, + // The label is renamed; the message id is not. "Source assessment" is already this codebase's + // user-facing name for the authoring copy (MarketplaceListingsTable's "Open source assessment", + // MarketplaceRestoreAuthoringButton's "Rebuild source assessment"). Renaming the id would orphan + // the key in all three locale files for a copy change; `authoring_assessment` is unaffected. + marketplaceAuthoring: { + id: 'course.assessments.index.marketplaceAuthoring', + defaultMessage: 'Source Assessment', + }, + marketplaceAuthoringHint: { + id: 'course.assessments.index.marketplaceAuthoringHint', + defaultMessage: + 'Listing ID {listingId} · editable working copy, not a published version', + }, + marketplaceAuthoringHintWithSource: { + id: 'course.assessments.index.marketplaceAuthoringHintWithSource', + defaultMessage: + 'Listing ID {listingId} · editable working copy, not a published version · from {source}', + }, + marketplaceVersionHint: { + id: 'course.assessments.index.marketplaceVersionHint', + defaultMessage: 'Listing ID {listingId}', + }, + marketplaceVersionHintWithSource: { + id: 'course.assessments.index.marketplaceVersionHintWithSource', + defaultMessage: 'Listing ID {listingId} · from {source}', + }, + marketplaceLive: { + id: 'course.assessments.index.marketplaceLive', + defaultMessage: 'Live', + }, + marketplaceLiveHint: { + id: 'course.assessments.index.marketplaceLiveHint', + defaultMessage: + 'The version the marketplace is serving right now. Adopting this listing copies this content.', + }, + marketplaceLatest: { + id: 'course.assessments.index.marketplaceLatest', + defaultMessage: 'Latest', + }, + marketplaceLatestHint: { + id: 'course.assessments.index.marketplaceLatestHint', + defaultMessage: + 'The newest version of this listing. Nothing is being served — the listing is off the marketplace.', + }, requirements: { id: 'course.assessment.show.requirements', defaultMessage: 'Requirements', diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index f23d634b075..0e901c93d61 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -26,6 +26,30 @@ export interface AchievementBadgeData { title: string; } +/** + * Which marketplace listing a container-course assessment belongs to. Present only for a system + * admin viewing the marketplace's container course — every assessment there keeps its original title + * verbatim, so this is the only thing telling them apart. + */ +export interface MarketplaceVersionData { + listingId: number; + /** Null for the listing's editable working copy, which is not a version at all. */ + publishedAt: string | null; + /** Denormalised at publish; survives deletion of the origin course, but may never have been set. */ + source: string | null; + /** + * Whether `Listing#current_version` points at this snapshot — the newest cut, not necessarily one + * anybody can adopt. Always false for the working copy, which is not a version. + */ + latest: boolean; + /** + * Whether the listing is on the marketplace (`Listing#published`), carried on every one of its + * rows including the working copy. Combined with `latest` this is what distinguishes a version + * being served from merely the newest one. True for a published orphan, which still serves. + */ + listed: boolean; +} + export interface AssessmentListData extends AssessmentActionsData { id: number; title: string; @@ -40,6 +64,7 @@ export interface AssessmentListData extends AssessmentActionsData { timeLimit?: number; isStartTimeBegin: boolean; isKoditsuAssessmentEnabled?: boolean; + marketplaceVersion?: MarketplaceVersionData; baseExp?: number; timeBonusExp?: number; @@ -69,6 +94,8 @@ export interface AssessmentsListData { tabTitle: string; tabUrl: string; canManageMonitor: boolean; + /** True only in the marketplace's snapshot container, viewed by a system admin. */ + isMarketplaceContainer: boolean; category: { id: number; title: string; @@ -92,6 +119,27 @@ interface GenerateQuestionBuilderData { url: string; } +export interface MarketplaceUpdateData { + /** + * When the content this copy was made from was published — its vintage, not the copy date. A + * version IS its publication datetime; there is no ordinal anywhere in this payload. + */ + adoptedVersionAt: string; + /** When the version the marketplace currently serves was published. */ + latestVersionAt: string; + /** + * Whether this copy may be replaced in place. False as soon as any non-phantom student of the + * course has a submission on it, in which case the banner offers no action at all. Advisory: the + * endpoint re-checks before destroying anything. + */ + canUpdateInPlace: boolean; + /** + * Staff and phantom test runs on this copy. They do not block the update, but it deletes them, so + * the confirmation prompt names the number first. + */ + testSubmissionCount: number; +} + export interface AssessmentData extends AssessmentActionsData { id: number; title: string; @@ -110,6 +158,13 @@ export interface AssessmentData extends AssessmentActionsData { }; isPublishedToMarketplace: boolean; marketplaceListingUrl: string; + /** Null unless a newer version of the adopted marketplace listing is available. */ + marketplaceUpdate: MarketplaceUpdateData | null; + /** + * Present only for a system admin viewing an assessment the marketplace owns inside its container + * course — a published snapshot or a listing's working copy. Same shape as the index row's badge. + */ + marketplaceVersion?: MarketplaceVersionData; requirements: { title: string; satisfied?: boolean; diff --git a/client/locales/en.json b/client/locales/en.json index 9b948475d95..eeb4b033950 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1571,6 +1571,27 @@ "course.assessment.generation.untitledQuestion": { "defaultMessage": "Untitled Question" }, + "course.assessment.marketplaceUpdateCompleted": { + "defaultMessage": "Assessment updated to the latest version. Refresh to see the latest version." + }, + "course.assessment.marketplaceUpdateConfirmBody": { + "defaultMessage": "This replaces this assessment's questions and materials with the version published on {latest}. It keeps its place in your course, its deadlines, and whether it is published." + }, + "course.assessment.marketplaceUpdateConfirmDeletion": { + "defaultMessage": "{count, plural, one {# test submission} other {# test submissions}} on this assessment will be deleted. No student has submitted work for it." + }, + "course.assessment.marketplaceUpdateConfirmTitle": { + "defaultMessage": "Update this assessment?" + }, + "course.assessment.marketplaceUpdateFailed": { + "defaultMessage": "Could not update this assessment." + }, + "course.assessment.marketplaceUpdateInPlace": { + "defaultMessage": "Update this assessment" + }, + "course.assessment.marketplaceUpdateStarted": { + "defaultMessage": "Updating this assessment…" + }, "course.assessment.question.multipleResponses.showOptions": { "defaultMessage": "Show Options" }, @@ -4268,6 +4289,15 @@ "course.assessments.index.hasTodo": { "defaultMessage": "Has TODO" }, + "course.assessments.index.marketplaceAuthoring": { + "defaultMessage": "Source Assessment" + }, + "course.assessments.index.marketplaceAuthoringHint": { + "defaultMessage": "Listing ID {listingId} · editable working copy, not a published version" + }, + "course.assessments.index.marketplaceAuthoringHintWithSource": { + "defaultMessage": "Listing ID {listingId} · editable working copy, not a published version · from {source}" + }, "course.assessments.index.neededFor": { "defaultMessage": "Needed for" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 9e5659f6d0c..4c09aad0491 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -1571,6 +1571,27 @@ "course.assessment.generation.untitledQuestion": { "defaultMessage": "제목 없는 문항" }, + "course.assessment.marketplaceUpdateCompleted": { + "defaultMessage": "평가가 최신 버전으로 업데이트되었습니다." + }, + "course.assessment.marketplaceUpdateConfirmBody": { + "defaultMessage": "이 작업은 이 평가의 문항과 자료를 {latest}에 게시된 버전으로 대체합니다. 코스 내 위치, 마감일, 게시 여부는 유지됩니다." + }, + "course.assessment.marketplaceUpdateConfirmDeletion": { + "defaultMessage": "이 평가의 {count, plural, one {#개의 테스트 제출} other {#개의 테스트 제출}}이 삭제됩니다. 학생 제출물은 없습니다." + }, + "course.assessment.marketplaceUpdateConfirmTitle": { + "defaultMessage": "이 평가를 업데이트하시겠습니까?" + }, + "course.assessment.marketplaceUpdateFailed": { + "defaultMessage": "이 평가를 업데이트할 수 없습니다." + }, + "course.assessment.marketplaceUpdateInPlace": { + "defaultMessage": "이 평가 업데이트" + }, + "course.assessment.marketplaceUpdateStarted": { + "defaultMessage": "이 평가를 업데이트하는 중…" + }, "course.assessment.question.multipleResponses.showOptions": { "defaultMessage": "옵션 보기" }, @@ -4250,6 +4271,15 @@ "course.assessments.index.hasTodo": { "defaultMessage": "할 일 있음" }, + "course.assessments.index.marketplaceAuthoring": { + "defaultMessage": "원본 평가" + }, + "course.assessments.index.marketplaceAuthoringHint": { + "defaultMessage": "등록 항목 ID {listingId} · 수정 가능한 작업 사본이며, 발행된 버전이 아닙니다" + }, + "course.assessments.index.marketplaceAuthoringHintWithSource": { + "defaultMessage": "등록 항목 ID {listingId} · 수정 가능한 작업 사본이며, 발행된 버전이 아닙니다 · 출처: {source}" + }, "course.assessments.index.neededFor": { "defaultMessage": "필요한 경우" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 32db60abaff..4b53a63a4d1 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -1562,6 +1562,27 @@ "course.assessment.generation.untitledQuestion": { "defaultMessage": "无标题题目" }, + "course.assessment.marketplaceUpdateCompleted": { + "defaultMessage": "评估已更新到最新版本。" + }, + "course.assessment.marketplaceUpdateConfirmBody": { + "defaultMessage": "这会将此评估的问题和资料替换为 {latest} 发布的版本。它会保留其在课程中的位置、截止日期以及发布状态。" + }, + "course.assessment.marketplaceUpdateConfirmDeletion": { + "defaultMessage": "此评估上的 {count, plural, one {# 个测试提交} other {# 个测试提交}} 将被删除。没有学生提交过作业。" + }, + "course.assessment.marketplaceUpdateConfirmTitle": { + "defaultMessage": "更新此评估?" + }, + "course.assessment.marketplaceUpdateFailed": { + "defaultMessage": "无法更新此评估。" + }, + "course.assessment.marketplaceUpdateInPlace": { + "defaultMessage": "更新此评估" + }, + "course.assessment.marketplaceUpdateStarted": { + "defaultMessage": "正在更新此评估…" + }, "course.assessment.question.multipleResponses.showOptions": { "defaultMessage": "显示选项" }, @@ -4244,6 +4265,15 @@ "course.assessments.index.hasTodo": { "defaultMessage": "显示待办事项" }, + "course.assessments.index.marketplaceAuthoring": { + "defaultMessage": "源评估" + }, + "course.assessments.index.marketplaceAuthoringHint": { + "defaultMessage": "市场条目 ID {listingId} · 可编辑的工作副本,非已发布版本" + }, + "course.assessments.index.marketplaceAuthoringHintWithSource": { + "defaultMessage": "市场条目 ID {listingId} · 可编辑的工作副本,非已发布版本 · 来自 {source}" + }, "course.assessments.index.neededFor": { "defaultMessage": "需要的" }, diff --git a/config/locales/en/course/assessment/assessments.yml b/config/locales/en/course/assessment/assessments.yml index 5af72a174a2..14477293b3b 100644 --- a/config/locales/en/course/assessment/assessments.yml +++ b/config/locales/en/course/assessment/assessments.yml @@ -1,6 +1,11 @@ en: course: assessment: + marketplace_adoptions: + apply_latest_version: + student_submissions_exist: >- + This assessment cannot be updated in place because students have already submitted + work for it. Import the latest version as a new assessment instead. assessments: invalid_questions_order: 'Invalid ordering for assessment questions' show: diff --git a/config/locales/ko/course/assessment/assessments.yml b/config/locales/ko/course/assessment/assessments.yml index aec245e5ec3..ad99f423c44 100644 --- a/config/locales/ko/course/assessment/assessments.yml +++ b/config/locales/ko/course/assessment/assessments.yml @@ -1,6 +1,11 @@ ko: course: assessment: + marketplace_adoptions: + apply_latest_version: + student_submissions_exist: >- + 학생들이 이미 이 평가에 제출한 작업이 있으므로 이 평가를 제자리에서 업데이트할 수 없습니다. + 대신 최신 버전을 새 평가로 가져오세요. assessments: invalid_questions_order: '평가 질문의 순서가 잘못되었습니다' show: diff --git a/config/locales/zh/course/assessment/assessments.yml b/config/locales/zh/course/assessment/assessments.yml index de0b696d652..6ce7a20cf36 100644 --- a/config/locales/zh/course/assessment/assessments.yml +++ b/config/locales/zh/course/assessment/assessments.yml @@ -1,6 +1,11 @@ zh: course: assessment: + marketplace_adoptions: + apply_latest_version: + student_submissions_exist: >- + 由于学生已经提交了此评估的作业,无法就地更新此评估。 + 请改为将最新版本导入为新的评估。 assessments: invalid_questions_order: '测验问题的权重无效' show: diff --git a/config/routes.rb b/config/routes.rb index f467870265a..9ce981134d6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -296,6 +296,9 @@ resource :marketplace_listing, only: [:create, :destroy] do post 'versions' => 'marketplace_listings#publish_version' end + resource :marketplace_adoption, only: [] do + post 'apply_latest_version' => 'marketplace_adoptions#apply_latest_version' + end namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index 98bc1d0c3d7..485e0a5d27d 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -29,6 +29,49 @@ end end + describe 'marketplaceUpdate' do + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + let(:copy) { create(:assessment, course: destination_course) } + let(:v1_at) { 10.days.ago.change(usec: 0) } + let(:v2_at) { 1.day.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, :versioned, + published: true, first_published_at: v1_at) + end + + before { controller_sign_in(controller, manager) } + + subject do + get :show, params: { course_id: destination_course.id, id: copy.id, format: :json } + end + + it 'is null for an assessment that was never adopted' do + subject + + expect(response.parsed_body['marketplaceUpdate']).to be_nil + end + + it 'carries the notice when a newer version exists' do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + v2 = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v2_at, published_by: listing.publisher) + listing.update!(current_version: v2) + + subject + + notice = response.parsed_body['marketplaceUpdate'] + expect(notice.keys).to contain_exactly('adoptedVersionAt', 'latestVersionAt', + 'canUpdateInPlace', 'testSubmissionCount') + expect(Time.zone.parse(notice['adoptedVersionAt'])).to be_within(1.second).of(v1_at) + expect(Time.zone.parse(notice['latestVersionAt'])).to be_within(1.second).of(v2_at) + end + end + context 'as a course manager (non-admin)' do let(:manager) { create(:course_manager, course: course).user } before { controller_sign_in(controller, manager) } @@ -39,5 +82,200 @@ end end end + + # Opening a container assessment must carry the identity its index row carries. Without it the + # snapshot, the listing's working copy and an ordinary draft are three indistinguishable pages — + # and the snapshot's lone marketplace control invites republishing immutable content as a listing + # of its own, whose source assessment would then be frozen inside the container. + describe 'GET #show — marketplace container context' do + let(:container) { create(:course, preview: true) } + let(:listing) do + create(:course_assessment_marketplace_listing, course: container, + source_course_name: 'MP Allowlist Source Course') + end + let(:working_copy) { listing.authoring_assessment } + let(:snapshot) { create(:assessment, course: container) } + let(:published_at) { 3.days.ago.change(usec: 0) } + let!(:version) do + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: snapshot, published_at: published_at, + published_by: listing.publisher).tap { |cut| listing.update!(current_version: cut) } + end + + def show_for(target_course, target_assessment) + get :show, as: :json, params: { course_id: target_course.id, id: target_assessment.id } + end + + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'dates a snapshot with the same fields as its index row' do + show_for(container, snapshot) + + label = response.parsed_body['marketplaceVersion'] + expect(label.keys).to contain_exactly('listingId', 'publishedAt', 'source', 'latest', + 'listed') + expect(label['listingId']).to eq(listing.id) + expect(label['source']).to eq('MP Allowlist Source Course') + expect(label['latest']).to be(true) + expect(label['listed']).to be(true) + expect(Time.zone.parse(label['publishedAt'])).to be_within(1.second).of(published_at) + end + + it 'reports the working copy as a non-version' do + show_for(container, working_copy) + + label = response.parsed_body['marketplaceVersion'] + expect(label['listingId']).to eq(listing.id) + expect(label['publishedAt']).to be_nil + expect(label['latest']).to be(false) + end + + it 'withholds publishing from a snapshot, which is already an existing listing content' do + show_for(container, snapshot) + + expect(response.parsed_body['permissions']).to include('canPublishToMarketplace' => false) + end + + it 'keeps publishing available on the working copy' do + show_for(container, working_copy) + + expect(response.parsed_body['permissions']).to include('canPublishToMarketplace' => true) + end + + # An assessment authored directly in the container is neither a snapshot nor a working copy. + # Publishing it is the supported way a marketplace-hosted listing comes to exist at all. + it 'keeps publishing available on an unlabelled container assessment' do + fresh = create(:assessment, course: container) + + show_for(container, fresh) + + expect(response.parsed_body).not_to have_key('marketplaceVersion') + expect(response.parsed_body['permissions']).to include('canPublishToMarketplace' => true) + end + + # The guard is the container's `preview` flag, mirroring the index: the same assessment + # outside the container must stay unlabelled. + it 'omits the context outside the container, even for a versioned assessment' do + in_normal_course = create(:assessment, course: course) + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: in_normal_course, published_at: 2.days.ago, + published_by: listing.publisher) + + show_for(course, in_normal_course) + + expect(response.parsed_body).not_to have_key('marketplaceVersion') + end + end + + # Previewers are enrolled into the container as managers. The context is admin-only navigation, + # exactly as on the index. + context 'as a non-admin manager of the container' do + before { controller_sign_in(controller, create(:course_manager, course: container).user) } + + it 'omits the context' do + show_for(container, snapshot) + + expect(response.parsed_body).not_to have_key('marketplaceVersion') + end + end + end + + describe 'version identity in the assessment payloads' do + render_views + + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + let(:copy) { create(:assessment, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + + before do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, published_by: listing.publisher) + listing.update!(current_version: version) + controller_sign_in(controller, manager) + end + + it 'dates both vintages on the update notice and carries no ordinal' do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + latest = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: 1.day.ago.change(usec: 0), published_by: listing.publisher) + listing.update!(current_version: latest) + + get :show, as: :json, params: { course_id: destination_course, id: copy } + + update = response.parsed_body['marketplaceUpdate'] + expect(update.keys).to contain_exactly('adoptedVersionAt', 'latestVersionAt', + 'canUpdateInPlace', 'testSubmissionCount') + expect(Time.zone.parse(update['adoptedVersionAt'])).to be_within(1.second).of(v1_at) + expect(Time.zone.parse(update['latestVersionAt'])). + to be_within(1.second).of(latest.published_at) + end + + it 'emits a null update notice when the copy is current' do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + + get :show, as: :json, params: { course_id: destination_course, id: copy } + + expect(response.parsed_body['marketplaceUpdate']).to be_nil + end + end + + describe 'the in-place update gate on the show payload' do + render_views + + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + let(:copy) { create(:assessment, :with_mcq_question, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + + before do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, published_by: listing.publisher) + listing.update!(current_version: version) + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + newer = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: 1.day.ago.change(usec: 0), published_by: listing.publisher) + listing.update!(current_version: newer) + controller_sign_in(controller, manager) + end + + it 'offers the in-place update on an unattempted copy' do + get :show, as: :json, params: { course_id: destination_course, id: copy } + + update = response.parsed_body['marketplaceUpdate'] + expect(update['canUpdateInPlace']).to be(true) + expect(update['testSubmissionCount']).to eq(0) + end + + it 'withholds the in-place update once a real student has attempted the copy' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + + get :show, as: :json, params: { course_id: destination_course, id: copy } + + expect(response.parsed_body['marketplaceUpdate']['canUpdateInPlace']).to be(false) + end + end end end diff --git a/spec/controllers/course/assessment/marketplace_adoptions_controller_spec.rb b/spec/controllers/course/assessment/marketplace_adoptions_controller_spec.rb new file mode 100644 index 00000000000..8b970244698 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace_adoptions_controller_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::MarketplaceAdoptionsController, type: :controller do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:destination_course) { create(:course) } + let(:copy) { create(:assessment, :with_mcq_question, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + let!(:v1) do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, published_by: listing.publisher) + listing.update!(current_version: version) + version + end + let!(:adoption) do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + end + let(:manager) { create(:course_manager, course: destination_course).user } + + describe 'POST #apply_latest_version' do + render_views + + with_active_job_queue_adapter(:test) do + def apply + post :apply_latest_version, as: :json, + params: { course_id: destination_course.id, assessment_id: copy.id } + end + + context 'as a course manager' do + before { controller_sign_in(controller, manager) } + + it 'enqueues the update and answers with the job url' do + expect { apply }.to have_enqueued_job(Course::Assessment::Marketplace::ApplyVersionJob) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['jobUrl']).to be_present + end + + # The client flag is advisory. A stale page must never be able to destroy student work. + it 'refuses when a real student has attempted the copy, whatever the client believed' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + + expect { apply }.not_to have_enqueued_job(Course::Assessment::Marketplace::ApplyVersionJob) + expect(response).to have_http_status(:unprocessable_content) + key = 'course.assessment.marketplace_adoptions.apply_latest_version.student_submissions_exist' + expect(I18n.t(key)).to eq(key) + expect(response.parsed_body['errors'].first).to eq(I18n.t(key)) + end + + it 'still allows the update when only staff have test submissions' do + create(:submission, :attempting, assessment: copy, creator: manager) + + expect { apply }.to have_enqueued_job(Course::Assessment::Marketplace::ApplyVersionJob) + end + + it 'responds 404 when the assessment was never adopted' do + other = create(:assessment, course: destination_course) + + post :apply_latest_version, as: :json, + params: { course_id: destination_course.id, assessment_id: other.id } + + expect(response).to have_http_status(:not_found) + end + end + + context 'as a course student' do + before { controller_sign_in(controller, create(:course_student, course: destination_course).user) } + + it 'is denied' do + expect { apply }.to raise_exception(CanCan::AccessDenied) + end + end + end + end + end +end diff --git a/spec/jobs/course/assessment/marketplace/apply_version_job_spec.rb b/spec/jobs/course/assessment/marketplace/apply_version_job_spec.rb new file mode 100644 index 00000000000..da9569b084a --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/apply_version_job_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ApplyVersionJob, type: :job do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:user) { create(:administrator) } + let(:source_course) { create(:course) } + let(:source_assessment) do + create(:assessment, :with_mcq_question, course: source_course, title: 'Marketplace Lab') + end + let(:destination_course) { create(:course) } + let!(:listing) do + Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) + end + let(:copy) do + create(:assessment, :with_mcq_question, course: destination_course, title: 'My Local Title') + end + let!(:adoption) do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, + adopted_version_at: listing.current_version.published_at) + end + + before do + source_assessment.update!(title: 'Marketplace Lab v2') + Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) + end + + def run_and_capture + job = described_class.new(copy, current_user: user) + job.perform_now + job.job + end + + it 'replaces the content and redirects back to the same assessment' do + job = run_and_capture + + expect(copy.reload.title).to eq('Marketplace Lab v2') + expect(job.redirect_to).to include("/courses/#{destination_course.id}/assessments/#{copy.id}") + end + + it 'reports the job as completed' do + job = run_and_capture + + expect(job.status).to eq('completed') + end + + it 'errors the job rather than raising when the listing serves nothing' do + listing.update!(current_version: nil) + + job = run_and_capture + + expect(job.status).to eq('errored') + end + + it 'errors instead of deleting work when a student attempt exists by execution time' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + + job = run_and_capture + + expect(job.status).to eq('errored') + expect(copy.reload.title).to eq('My Local Title') + expect(copy.questions).not_to be_empty + end + end +end diff --git a/spec/models/course/assessment/marketplace/adoption_spec.rb b/spec/models/course/assessment/marketplace/adoption_spec.rb index 7b0e9c38488..f0c525eceb2 100644 --- a/spec/models/course/assessment/marketplace/adoption_spec.rb +++ b/spec/models/course/assessment/marketplace/adoption_spec.rb @@ -20,5 +20,171 @@ adoption.duplicated_assessment.destroy expect(described_class.exists?(adoption.id)).to be(false) end + + describe '.update_notice_for' do + let(:destination_course) { create(:course) } + let(:copy) { create(:assessment, :with_mcq_question, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + let!(:v1) do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, + published_by: listing.publisher) + listing.update!(current_version: version) + version + end + + def cut_version(published_at) + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: published_at, + published_by: listing.publisher) + listing.update!(current_version: version) + version + end + + def adopt(adopted_version_at:) + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: adopted_version_at) + end + + it 'returns nil when the assessment was never adopted' do + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'returns nil when the adopted vintage is the current one' do + adopt(adopted_version_at: v1_at) + + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'returns the notice when a newer vintage exists' do + adopt(adopted_version_at: v1_at) + v2 = cut_version(2.days.ago.change(usec: 0)) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:adopted_version_at]).to be_within(1.second).of(v1_at) + expect(notice[:latest_version_at]).to be_within(1.second).of(v2.published_at) + end + + # The banner speaks in dates only — there is no ordinal anywhere in the payload. + it 'carries no version ordinal in the notice' do + adopt(adopted_version_at: v1_at) + cut_version(2.days.ago.change(usec: 0)) + + notice = described_class.update_notice_for(copy.id) + + expect(notice.keys).to contain_exactly(:adopted_version_at, :latest_version_at, + :can_update_in_place, :test_submission_count) + end + + it 'dates a mid-chain adopted vintage from the adoption row itself' do + v2 = cut_version(10.days.ago.change(usec: 0)) + adopt(adopted_version_at: v2.published_at) + cut_version(1.day.ago.change(usec: 0)) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:adopted_version_at]).to be_within(1.second).of(v2.published_at) + end + + # Fail toward silence: a false "an update is waiting" trains managers to ignore the banner. + it 'returns nil when the adopted vintage is unknown, rather than guessing' do + adopt(adopted_version_at: nil) + cut_version(2.days.ago.change(usec: 0)) + + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'returns nil when the listing has no current version at all' do + adoption = adopt(adopted_version_at: v1_at) + listing.update!(current_version: nil) + + expect(described_class.update_notice_for(adoption.duplicated_assessment_id)).to be_nil + end + + # An adopter whose copy is somehow NEWER than what the listing serves must not be told an + # update is waiting — the comparison is strictly greater-than, not merely different. + it 'returns nil when the adopted vintage is newer than the served one' do + adopt(adopted_version_at: 1.hour.ago.change(usec: 0)) + + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'resolves when the snapshot lives in another tenant, with no tenant escape' do + adopt(adopted_version_at: v1_at) + other_instance = create(:instance) + published = 1.day.ago.change(usec: 0) + ActsAsTenant.without_tenant do + snapshot = ActsAsTenant.with_tenant(other_instance) { create(:assessment) } + v2 = create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: snapshot, published_at: published, + published_by: listing.publisher) + listing.update!(current_version: v2) + end + + expect(described_class.update_notice_for(copy.id)[:latest_version_at]). + to be_within(1.second).of(published) + end + + describe 'the in-place update gate' do + before do + adopt(adopted_version_at: v1_at) + cut_version(2.days.ago.change(usec: 0)) + end + + it 'allows the in-place update when nobody has attempted the copy' do + notice = described_class.update_notice_for(copy.id) + + expect(notice[:can_update_in_place]).to be(true) + expect(notice[:test_submission_count]).to eq(0) + end + + it 'reports the test submissions the update would delete' do + manager = create(:course_manager, course: destination_course) + create(:submission, :attempting, assessment: copy, creator: manager.user) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:can_update_in_place]).to be(true) + expect(notice[:test_submission_count]).to eq(1) + end + + it 'refuses the in-place update once a real student has attempted the copy' do + student = create(:course_student, course: destination_course) + create(:submission, :attempting, assessment: copy, creator: student.user) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:can_update_in_place]).to be(false) + end + end + end + + describe '#latest_version_at' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + let(:adoption) do + create(:course_assessment_marketplace_adoption, listing: listing, + adopted_version_at: 1.day.ago) + end + + it 'reports the served version publish date' do + expect(adoption.latest_version_at). + to be_within(1.second).of(listing.current_version.published_at) + end + + it 'is nil for a listing with no current version' do + listing.update!(current_version: nil) + + expect(adoption.reload.latest_version_at).to be_nil + end + end end end diff --git a/spec/services/course/assessment/marketplace/apply_version_service_spec.rb b/spec/services/course/assessment/marketplace/apply_version_service_spec.rb new file mode 100644 index 00000000000..cdbba813600 --- /dev/null +++ b/spec/services/course/assessment/marketplace/apply_version_service_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ApplyVersionService, type: :service do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:user) { create(:administrator) } + let(:source_course) { create(:course) } + let(:source_assessment) do + create(:assessment, :with_mcq_question, course: source_course, title: 'Marketplace Lab') + end + let(:destination_course) { create(:course) } + let!(:listing) do + Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) + end + let(:copy) do + create(:assessment, :with_mcq_question, course: destination_course, title: 'My Local Title') + end + let!(:adoption) do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, + adopted_version_at: listing.current_version.published_at) + end + + def cut_newer_version + source_assessment.update!(title: 'Marketplace Lab v2') + Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) + end + + describe '.apply' do + it 'keeps the same assessment row rather than making a new one' do + cut_newer_version + + expect { described_class.apply(copy, user) }. + not_to(change { Course::Assessment.where(id: copy.id).count }) + + expect(copy.reload).to be_present + end + + it 'destroys the throwaway copy it duplicated to' do + cut_newer_version + + expect { described_class.apply(copy, user) }. + to change { destination_course.assessments.count }.by(0) + end + + # Restamping the vintage is the ONLY thing that retires the update banner — there is no + # dismissal state alongside it to clear. + it 'advances the adoption to the served vintage' do + version = cut_newer_version + + described_class.apply(copy, user) + + expect(adoption.reload.adopted_version_at).to be_within(1.second).of(version.published_at) + expect(adoption.reload).not_to be_update_pending + end + + it 'takes the title from the new version' do + cut_newer_version + + described_class.apply(copy, user) + + expect(copy.reload.title).to eq('Marketplace Lab v2') + end + + # Slice 3's rule, minus self-collision: the copy's own old title must not count against it. + it 'renames when the new title is already taken in the destination course' do + version = cut_newer_version + create(:assessment, course: destination_course, title: 'Marketplace Lab v2') + + described_class.apply(copy, user) + + expect(copy.reload.title).to eq("Marketplace Lab v2 [#{version.published_at.strftime('%d %b %Y')}]") + end + + it 'does not rename when only its own old title would collide' do + cut_newer_version + + described_class.apply(copy, user) + + expect(copy.reload.title).to eq('Marketplace Lab v2') + end + + it 'keeps the tab position the manager chose' do + cut_newer_version + original_tab_id = copy.tab_id + + described_class.apply(copy, user) + + expect(copy.reload.tab_id).to eq(original_tab_id) + end + + # Replacing content must not silently expose or hide an assessment. + it 'keeps the published state' do + cut_newer_version + copy.update!(published: true) + + described_class.apply(copy, user) + + expect(copy.reload.published).to be(true) + end + + it 'replaces the questions with the new version questions' do + old_question_ids = copy.questions.map(&:id) + cut_newer_version + + described_class.apply(copy, user) + + expect(copy.reload.questions.map(&:id)).not_to match_array(old_question_ids) + expect(copy.questions).not_to be_empty + expect(Course::Assessment::Question.where(id: old_question_ids)).to be_empty + end + + # Staff test runs do not block the update, but their answers point at questions that no longer + # exist, so they go with them. + it 'destroys the submissions that were on the copy' do + manager = create(:course_manager, course: destination_course) + create(:submission, :attempting, assessment: copy, creator: manager.user) + cut_newer_version + + expect { described_class.apply(copy, user) }. + to change { Course::Assessment::Submission.where(assessment_id: copy.id).count }.to(0) + end + + it 'refuses once a real student has attempted by execution time' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + old_question_ids = copy.questions.map(&:id) + cut_newer_version + + expect { described_class.apply(copy, user) }. + to raise_error(ArgumentError, /students have already submitted/) + + expect(copy.reload.title).to eq('My Local Title') + expect(copy.questions.map(&:id)).to match_array(old_question_ids) + expect(adoption.reload.adopted_version_at).to be_within(1.second).of(listing.first_published_at) + end + + # They were computed against a schedule that no longer exists. `find_or_create_personal_time_for` + # rebuilds them on demand from the new reference times, so this is not data loss. + it 'destroys personal times anchored to the replaced schedule' do + student = create(:course_student, course: destination_course) + copy.lesson_plan_item.find_or_create_personal_time_for(student).save! + cut_newer_version + + expect { described_class.apply(copy, user) }. + to change { Course::PersonalTime.where(lesson_plan_item_id: copy.lesson_plan_item.id).count }.to(0) + end + + it 'refuses an assessment that was never adopted' do + plain = create(:assessment, course: destination_course) + + expect { described_class.apply(plain, user) }.to raise_error(ArgumentError) + end + + it 'refuses a listing with no current version' do + listing.update!(current_version: nil) + + expect { described_class.apply(copy, user) }.to raise_error(ArgumentError) + end + + # The whole point of one transaction: a half-replaced assessment has no questions and no way back. + it 'leaves the copy untouched when the transplant fails' do + cut_newer_version + allow_any_instance_of(described_class).to receive(:copy_attributes!).and_raise('boom') + + expect { described_class.apply(copy, user) }.to raise_error('boom') + expect(copy.reload.questions).not_to be_empty + expect(copy.title).to eq('My Local Title') + end + end + end +end From 2f0313101d839a2e153666f3bd8b8eca50cc4cd0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:31:21 +0800 Subject: [PATCH 09/28] feat(marketplace): badge container snapshots in the assessment index Inside the preview container every listing's snapshots share one title, so the index chip dates each one and links it to the listing it belongs to. --- .../assessments/index.json.jbuilder | 18 + .../AssessmentsIndex/AssessmentsTable.tsx | 149 ++++++++ .../__test__/AssessmentsTable.test.tsx | 318 ++++++++++++++++++ .../__test__/StatusBadges.test.tsx | 41 +++ .../bundles/course/assessment/translations.ts | 28 ++ .../assessments_marketplace_spec.rb | 148 ++++++++ 6 files changed, 702 insertions(+) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx diff --git a/app/views/course/assessment/assessments/index.json.jbuilder b/app/views/course/assessment/assessments/index.json.jbuilder index a2fbca99c0a..14a964d7045 100644 --- a/app/views/course/assessment/assessments/index.json.jbuilder +++ b/app/views/course/assessment/assessments/index.json.jbuilder @@ -1,6 +1,8 @@ # frozen_string_literal: true achievements_enabled = !current_component_host[:course_achievements_component].nil? submissions_hash = @assessments.to_h { |assessment| [assessment.id, assessment.submissions] } +# Empty for every course except the marketplace's snapshot container viewed by a system admin. +marketplace_versions = defined?(@marketplace_versions) ? @marketplace_versions : {} json.display do json.isStudent current_course_user&.student? || false @@ -14,6 +16,11 @@ json.display do json.canCreateAssessments can?(:create, Course::Assessment.new(tab: @tab)) json.canManageMonitor @can_manage_monitor && @monitoring_component_enabled + # True only in the marketplace's snapshot container, viewed by a system admin. Switches on the + # container-only Listing/Version/Source columns and the search toolbar — every other course's + # assessments index must stay exactly as it was. + json.isMarketplaceContainer @marketplace_container || false + json.category do json.id @category.id json.title @category.title @@ -51,6 +58,17 @@ json.assessments @assessments do |assessment| json.isKoditsuAssessmentEnabled assessment.is_koditsu_enabled end + marketplace_version = marketplace_versions[assessment.id] + if marketplace_version + json.marketplaceVersion do + json.listingId marketplace_version[:listing_id] + json.publishedAt marketplace_version[:published_at] + json.source marketplace_version[:source] + json.latest marketplace_version[:latest] + json.listed marketplace_version[:listed] + end + end + assessment_with_loaded_timeline = @items_hash[assessment.id].actable # assessment_with_loaded_timeline is passed below since the timeline is already preloaded and will be checked can_attempt_assessment = can?(:attempt, assessment_with_loaded_timeline) diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx index 85602e2722c..d7812760df4 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { AssessmentListData, AssessmentsListData, @@ -13,6 +14,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../translations'; import ActionButtons from './ActionButtons'; +import MarketplaceVersionChip from './MarketplaceVersionChip'; import StatusBadges from './StatusBadges'; interface AssessmentsTableProps { @@ -23,10 +25,66 @@ const AssessmentsTable = (props: AssessmentsTableProps): JSX.Element => { const { display, assessments, totalStudentCount } = props.assessments; const { t } = useTranslation(); + const isContainer = display.isMarketplaceContainer; + + // One label per LISTING, taken from that listing's newest row. A listing's title can change between + // publishes, so labelling each row from its own title would split one listing into two filter + // entries. Every row of a listing is in this payload — snapshots all duplicate into the container's + // default tab — and the filter is client-side over loaded rows regardless. + // + // ISO-8601 strings compare lexicographically in date order, so no parsing is needed. The working + // copy's null date sorts below every real snapshot, and wins only when it is the listing's sole row. + const listingLabels = useMemo((): Record => { + const newest: Record = {}; + + assessments.forEach((assessment) => { + const version = assessment.marketplaceVersion; + if (!version) return; + + const held = newest[version.listingId]; + const publishedAt = version.publishedAt ?? ''; + if (!held || publishedAt > held.publishedAt) + newest[version.listingId] = { title: assessment.title, publishedAt }; + }); + + return Object.fromEntries( + Object.entries(newest).map(([listingId, held]) => [ + listingId, + t(translations.marketplaceListingLabel, { + title: held.title, + listingId, + }), + ]), + ); + }, [assessments, t]); + + const listingLabelFor = (assessment: AssessmentListData): string => + assessment.marketplaceVersion + ? listingLabels[assessment.marketplaceVersion.listingId] + : ''; + + /** + * Live / Latest / Older version / Source Assessment — null for an assessment that belongs to no + * listing. Live and Latest are mutually exclusive: both mean "newest cut", and Live additionally + * means the listing is on the marketplace, so it stands in for the weaker label. + */ + const versionKindFor = (assessment: AssessmentListData): string | null => { + const version = assessment.marketplaceVersion; + if (!version) return null; + if (version.publishedAt === null) + return t(translations.marketplaceAuthoring); + if (!version.latest) return t(translations.marketplaceOlderVersion); + + return version.listed + ? t(translations.marketplaceLive) + : t(translations.marketplaceLatest); + }; + const columns: ColumnTemplate[] = [ { of: 'title', title: t(translations.title), + searchable: isContainer, cell: (assessment) => (
), }, + { + id: 'marketplaceListing', + title: t(translations.marketplaceListingColumn), + unless: !isContainer, + filterable: true, + // A filter rather than a search, unlike Source beside it: the rows of one listing are textually + // IDENTICAL — same title, same source — so no query string separates them. This is the only + // axis search cannot express, which is what earns it a menu despite growing with the catalogue. + // + // The filter value is the rendered label, not the bare id: `uniqueFilterValues` sorts its values + // as strings, so ids would order the menu "10" before "4". + filterProps: { + getValue: (assessment) => + assessment.marketplaceVersion ? [listingLabelFor(assessment)] : [], + shouldInclude: (assessment, filterValue?: string[]) => + !filterValue?.length || + filterValue.includes(listingLabelFor(assessment)), + }, + cell: (assessment) => + assessment.marketplaceVersion ? ( + // An in-app route, so react-router `to` — the system-admin routes and the course routes are + // children of one router (see routers/AuthenticatedApp.tsx). + + {listingLabelFor(assessment)} + + ) : ( + t(translations.marketplaceNotAVersion) + ), + }, + { + id: 'marketplaceVersion', + title: t(translations.marketplaceVersionColumn), + unless: !isContainer, + sortable: true, + filterable: true, + // Sorts on the publication instant, not the rendered label. The server orders by + // `ordered_by_date_and_title`, and every snapshot of a listing inherits the origin's identical + // start_at AND title — so siblings have no tiebreak and their order can differ between loads. + // This column is how an admin pins them down. + accessorFn: (assessment) => + assessment.marketplaceVersion?.publishedAt ?? '', + filterProps: { + getValue: (assessment): string[] => { + const kind = versionKindFor(assessment); + return kind ? [kind] : []; + }, + shouldInclude: (assessment, filterValue?: string[]) => + !filterValue?.length || + filterValue.includes(versionKindFor(assessment) ?? ''), + }, + cell: (assessment) => + assessment.marketplaceVersion ? ( + + ) : ( + t(translations.marketplaceNotAVersion) + ), + }, + { + id: 'marketplaceSource', + title: t(translations.marketplaceSourceColumn), + unless: !isContainer, + sortable: true, + searchable: true, + // Deliberately NOT filterable, mirroring MarketplaceListingsTable: source courses number in the + // hundreds, most contributing one or two listings, and the filter is client-side over loaded + // rows — a course menu would only grow as the feature succeeds. + accessorFn: (assessment) => assessment.marketplaceVersion?.source ?? '', + cell: (assessment) => + assessment.marketplaceVersion?.source ?? + t(translations.marketplaceNotAVersion), + }, { of: 'baseExp', title: t(translations.exp), @@ -185,6 +317,23 @@ const AssessmentsTable = (props: AssessmentsTableProps): JSX.Element => { }` } getRowId={(assessment): string => assessment.id.toString()} + // Container only: search and filter are the only way this empty state is reachable, and they + // only exist in the container. Every other course's index still relies on the + // `assessments.length === 0` check above, which covers "no assessments at all" but never "no + // assessments match the current search/filter" since there is no search/filter to produce it. + renderEmpty={ + isContainer ? ( + + ) : undefined + } + // Container only. Every other course's assessments index has never had a toolbar, and passing + // these unconditionally would give all of them one. + search={ + isContainer + ? { searchPlaceholder: t(translations.marketplaceSearchText) } + : undefined + } + toolbar={isContainer ? { show: true } : undefined} /> ); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx new file mode 100644 index 00000000000..73ef86ed8b4 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx @@ -0,0 +1,318 @@ +import userEvent from '@testing-library/user-event'; +import { render, waitFor, within } from 'test-utils'; +import { + AssessmentListData, + AssessmentsListData, +} from 'types/course/assessment/assessments'; + +import AssessmentsTable from '../AssessmentsTable'; + +const SEARCH_PLACEHOLDER = 'Search by assessment title or source course'; +const NO_RESULTS_MESSAGE = "Whoops, there's nothing to see here, yet!"; + +const assessment = ( + overrides: Partial = {}, +): AssessmentListData => ({ + id: 1, + title: 'Recursion', + status: 'open', + actionButtonUrl: null, + passwordProtected: false, + published: true, + autograded: false, + hasPersonalTimes: false, + affectsPersonalTimes: false, + url: '/courses/1/assessments/1', + conditionSatisfied: true, + startAt: { isFixed: false, effectiveTime: null, referenceTime: null }, + isStartTimeBegin: true, + ...overrides, +}); + +const listData = ( + assessments: AssessmentListData[], + isMarketplaceContainer: boolean, +): AssessmentsListData => ({ + display: { + isStudent: false, + isGamified: false, + isKoditsuExamEnabled: false, + timelineAlgorithm: 'fixed', + allowRandomization: false, + isAchievementsEnabled: false, + isMonitoringEnabled: false, + bonusAttributes: false, + endTimes: false, + canCreateAssessments: true, + tabId: 1, + tabTitle: 'Assessments: Default', + tabUrl: '/courses/1/assessments', + canManageMonitor: false, + isMarketplaceContainer, + category: { + id: 1, + title: 'Assessments', + tabs: [{ id: 1, title: 'Default' }], + }, + }, + assessments, +}); + +/** + * Four rows across three listings, covering every version kind: two cuts of a published listing + * (one served, one superseded), the single served cut of another, and the newest cut of a listing + * that has been taken off the marketplace. + */ +const containerRows = (): AssessmentListData[] => [ + assessment({ + id: 1, + title: 'Publish me 2', + marketplaceVersion: { + listingId: 4, + publishedAt: '2026-07-29T01:01:00Z', + source: 'Marketplace Preview Fixtures', + latest: false, + listed: true, + }, + }), + assessment({ + id: 2, + title: 'Publish me 2', + marketplaceVersion: { + listingId: 4, + publishedAt: '2026-07-29T01:04:00Z', + source: 'Marketplace Preview Fixtures', + latest: true, + listed: true, + }, + }), + assessment({ + id: 3, + title: 'Listed MCQ', + marketplaceVersion: { + listingId: 2, + publishedAt: '2026-07-29T00:59:00Z', + source: 'Other Source Course', + latest: true, + listed: true, + }, + }), + assessment({ + id: 4, + title: 'Taken down', + marketplaceVersion: { + listingId: 5, + publishedAt: '2026-07-29T02:07:00Z', + source: 'Retired Source Course', + latest: true, + listed: false, + }, + }), +]; + +// Column headers are matched by REGEX, never by an exact string: a filterable column's header cell +// also contains the filter IconButton, whose tooltip contributes "Filter" to the cell's accessible +// name (MUI applies the tooltip title as `aria-label` on a child with no text of its own). +describe(' in the marketplace container', () => { + it('adds the Listing, Version and Source columns', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('columnheader', { name: /Listing/ }), + ).toBeInTheDocument(); + expect( + page.getByRole('columnheader', { name: /Version/ }), + ).toBeInTheDocument(); + expect( + page.getByRole('columnheader', { name: /Source/ }), + ).toBeInTheDocument(); + }); + + // The container tab is the ONLY place these belong. Leaking them would rewrite the assessments + // index for every course in the deployment. + it('shows none of them, and no search box, in an ordinary course', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('link', { name: 'Recursion' }), + ).toBeInTheDocument(); + expect( + page.queryByRole('columnheader', { name: /Listing/ }), + ).not.toBeInTheDocument(); + expect( + page.queryByRole('columnheader', { name: /Version/ }), + ).not.toBeInTheDocument(); + expect( + page.queryByRole('columnheader', { name: /Source/ }), + ).not.toBeInTheDocument(); + expect( + page.queryByPlaceholderText(SEARCH_PLACEHOLDER), + ).not.toBeInTheDocument(); + // Generic, rather than keyed off our placeholder text: `MuiTableToolbar`'s `SearchField` falls + // back to a generic "Search" placeholder whenever the toolbar renders but `search` is unset, so a + // toolbar leaking in unconditionally would still pass the placeholder-only check above. + expect(page.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('offers a search box in the container', async () => { + const page = render( + , + ); + + expect( + await page.findByPlaceholderText(SEARCH_PLACEHOLDER), + ).toBeInTheDocument(); + }); + + // Source course is searchable rather than filterable, matching the decision already recorded on + // MarketplaceListingsTable: courses number in the hundreds and a menu would grow without bound. + it('narrows to one listing by searching its source course', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.type( + await page.findByPlaceholderText(SEARCH_PLACEHOLDER), + 'Other Source', + ); + + expect( + await page.findByRole('link', { name: 'Listed MCQ' }), + ).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: 'Publish me 2' }), + ).not.toBeInTheDocument(); + expect( + page.queryByRole('link', { name: 'Taken down' }), + ).not.toBeInTheDocument(); + }); + + // The reason the Listing axis is a filter and not a search: these two rows are textually + // identical, so no search string can separate them from the third. + it('labels every row of one listing identically, using its newest title', async () => { + const page = render( + , + ); + + expect( + await page.findAllByRole('link', { name: 'Publish me 2 · ID 4' }), + ).toHaveLength(2); + expect( + page.getAllByRole('link', { name: 'Listed MCQ · ID 2' }), + ).toHaveLength(1); + }); + + it('links a listing to its admin history page', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('link', { name: 'Listed MCQ · ID 2' }), + ).toHaveAttribute('href', '/admin/marketplace_listings/2'); + }); + + it('shows the Live chip only on the served snapshot of each published listing', async () => { + const page = render( + , + ); + + // Two published listings, one served snapshot each. The superseded cut and the unlisted + // listing's newest cut are both excluded. + expect(await page.findAllByText('Live')).toHaveLength(2); + }); + + // An unlisted listing still has a newest version — the one an admin re-publishing acts on — but + // nothing is being served, so it must read Latest and never Live. + it('marks an unlisted listing’s newest version Latest rather than Live', async () => { + const page = render( + , + ); + + expect(await page.findByText('Latest')).toBeInTheDocument(); + + const takenDownRow = page + .getByRole('link', { name: 'Taken down' }) + .closest('tr') as HTMLElement; + expect(within(takenDownRow).getByText('Latest')).toBeInTheDocument(); + expect(within(takenDownRow).queryByText('Live')).not.toBeInTheDocument(); + }); + + // Selecting Live is "what is the marketplace serving right now" in one click. The filter button is + // addressed by its 'Filter' name because the header also holds a sort button. + it('isolates what the marketplace is serving through the Version filter', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + const versionHeader = await page.findByRole('columnheader', { + name: /Version/, + }); + await user.click( + within(versionHeader).getByRole('button', { name: 'Filter' }), + ); + await user.click(await page.findByRole('menuitem', { name: 'Live' })); + // An open MUI menu marks the rest of the page `aria-hidden`, so the table rows are unqueryable + // until it is closed — matching the established pattern in MarketplaceListingsIndex.test.tsx. + await user.keyboard('{Escape}'); + await waitFor(() => + expect(page.queryByRole('menu')).not.toBeInTheDocument(), + ); + + // Listing 4's 9:04 cut survives and its 9:01 sibling does not; listing 2's only cut survives; + // the unlisted listing's newest cut is excluded because nothing of it is being served. + expect(page.getAllByRole('link', { name: 'Publish me 2' })).toHaveLength(1); + expect(page.getByRole('link', { name: 'Listed MCQ' })).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: 'Taken down' }), + ).not.toBeInTheDocument(); + }); + + // Unlike the all-or-nothing `assessments.length === 0` case (covered elsewhere), a search or + // filter that matches nothing is reached with rows still in the payload, so the empty note has to + // come from the table itself rather than a check before it. + it('shows an empty state when the search matches nothing, but not while rows still match', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + const search = await page.findByPlaceholderText(SEARCH_PLACEHOLDER); + + expect(page.queryByText(NO_RESULTS_MESSAGE)).not.toBeInTheDocument(); + + await user.type(search, 'No source course matches this string'); + + expect(await page.findByText(NO_RESULTS_MESSAGE)).toBeInTheDocument(); + }); + + it('leaves the Listing, Version and Source cells empty for an assessment authored in the container', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('link', { name: 'Hand-made in the container' }), + ).toBeInTheDocument(); + + // Indexed off the row rather than counting em dashes across the whole table, so an unrelated + // column rendering one cannot silently satisfy this. + const cells = within(page.getAllByRole('row')[1]).getAllByRole('cell'); + expect(cells[1]).toHaveTextContent('—'); + expect(cells[2]).toHaveTextContent('—'); + expect(cells[3]).toHaveTextContent('—'); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx new file mode 100644 index 00000000000..1f24bb7f0e6 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from 'test-utils'; +import { AssessmentListData } from 'types/course/assessment/assessments'; + +import StatusBadges from '../StatusBadges'; + +const assessment = ( + overrides: Partial = {}, +): AssessmentListData => ({ + id: 1, + title: 'Recursion', + status: 'open', + actionButtonUrl: null, + passwordProtected: false, + published: true, + autograded: false, + hasPersonalTimes: false, + affectsPersonalTimes: false, + url: '/courses/1/assessments/1', + conditionSatisfied: true, + startAt: { isFixed: false, effectiveTime: null, referenceTime: null }, + isStartTimeBegin: true, + ...overrides, +}); + +const renderBadges = (data: AssessmentListData): void => { + render( + , + ); +}; + +// The marketplace cases that used to live here moved with the chip: two to +// MarketplaceVersionChip.test.tsx in Task 3, and the rest to AssessmentsTable.test.tsx, which is +// where the chip now renders. They are deleted rather than inverted into absence assertions — a +// removed behaviour gets its tests removed, not rewritten to assert it is gone. +describe('', () => { + it('marks an unpublished assessment as a draft', async () => { + renderBadges(assessment({ published: false })); + + expect(await screen.findByText('Draft')).toBeVisible(); + }); +}); diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 4143fd2a042..37e70a92492 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -249,6 +249,34 @@ const translations = defineMessages({ defaultMessage: 'The newest version of this listing. Nothing is being served — the listing is off the marketplace.', }, + marketplaceListingColumn: { + id: 'course.assessments.index.marketplaceListingColumn', + defaultMessage: 'Listing', + }, + marketplaceVersionColumn: { + id: 'course.assessments.index.marketplaceVersionColumn', + defaultMessage: 'Version', + }, + marketplaceSourceColumn: { + id: 'course.assessments.index.marketplaceSourceColumn', + defaultMessage: 'Source', + }, + marketplaceListingLabel: { + id: 'course.assessments.index.marketplaceListingLabel', + defaultMessage: '{title} · ID {listingId}', + }, + marketplaceOlderVersion: { + id: 'course.assessments.index.marketplaceOlderVersion', + defaultMessage: 'Older version', + }, + marketplaceNotAVersion: { + id: 'course.assessments.index.marketplaceNotAVersion', + defaultMessage: '—', + }, + marketplaceSearchText: { + id: 'course.assessments.index.marketplaceSearchText', + defaultMessage: 'Search by assessment title or source course', + }, requirements: { id: 'course.assessment.show.requirements', defaultMessage: 'Requirements', diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index 485e0a5d27d..cfcc0ce4ba6 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -83,6 +83,139 @@ end end + # Snapshots keep their original title and share one tab of the container course, so the badge is + # the only thing distinguishing them. It must stay off every normal course's index (hot path) and + # away from the previewers who are enrolled into the container as managers. + describe 'GET #index — marketplace version badge' do + let(:container) { create(:course, preview: true) } + let(:snapshot) { create(:assessment, course: container) } + let(:listing) do + create(:course_assessment_marketplace_listing, source_course_name: 'MP Allowlist Source Course') + end + let(:published_at) { 3.days.ago.change(usec: 0) } + let(:outside_published_at) { 2.days.ago.change(usec: 0) } + let!(:version) do + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: snapshot, published_at: published_at, + published_by: listing.publisher) + end + + def index_for(target_course) + get :index, as: :json, params: { course_id: target_course.id } + end + + def payload_for(target_assessment) + response.parsed_body['assessments'].find { |json| json['id'] == target_assessment.id } + end + + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'labels a container snapshot with its published date and provenance' do + index_for(container) + + label = payload_for(snapshot)['marketplaceVersion'] + expect(label.keys).to contain_exactly('listingId', 'publishedAt', 'source', 'latest', + 'listed') + expect(label['listingId']).to eq(listing.id) + expect(label['source']).to eq('MP Allowlist Source Course') + expect(Time.zone.parse(label['publishedAt'])).to be_within(1.second).of(published_at) + end + + # The guard is the container's `preview` flag, not the mere existence of a version row: the + # same assessment id outside the container must stay unlabelled. + it 'omits the badge outside the container, even for a versioned assessment' do + in_normal_course = create(:assessment, course: course) + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: in_normal_course, published_at: outside_published_at, + published_by: listing.publisher) + + index_for(course) + + expect(payload_for(in_normal_course)).not_to have_key('marketplaceVersion') + end + + it 'does not query listing versions for a normal course' do + expect(Course::Assessment::Marketplace::ListingVersion).not_to receive(:labels_for_assessments) + + index_for(course) + end + + it 'marks the served snapshot as the latest' do + listing.update!(current_version: version) + + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['latest']).to be(true) + end + + it 'does not mark a superseded snapshot as the latest' do + pointed_at_snapshot = create(:assessment, course: container) + pointed_at = create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: pointed_at_snapshot, published_at: 1.day.ago, + published_by: listing.publisher) + listing.update!(current_version: pointed_at) + + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['latest']).to be(false) + expect(payload_for(pointed_at_snapshot)['marketplaceVersion']['latest']).to be(true) + end + + it 'reports whether the listing is on the marketplace' do + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['listed']).to be(true) + end + + it 'reports an unlisted listing as not listed' do + listing.update!(published: false) + + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['listed']).to be(false) + end + + it 'flags the container so the client can show its own columns and toolbar' do + index_for(container) + + expect(response.parsed_body['display']).to include('isMarketplaceContainer' => true) + end + + # The flag drives a search toolbar and three extra columns. Leaking it into ordinary courses + # would change the assessments index for every course in the deployment. + it 'does not flag an ordinary course as the container' do + index_for(course) + + expect(response.parsed_body['display']).to include('isMarketplaceContainer' => false) + end + end + + context 'as a non-admin manager of the container' do + before { controller_sign_in(controller, create(:course_manager, course: container).user) } + + it 'omits the badge' do + index_for(container) + + expect(payload_for(snapshot)).not_to have_key('marketplaceVersion') + end + + it 'does not query listing versions' do + expect(Course::Assessment::Marketplace::ListingVersion).not_to receive(:labels_for_assessments) + + index_for(container) + end + + # Previewers are enrolled into the container as managers. They must see neither the badge nor + # the admin-only navigation the flag switches on. + it 'does not flag the container' do + index_for(container) + + expect(response.parsed_body['display']).to include('isMarketplaceContainer' => false) + end + end + end + # Opening a container assessment must carry the identity its index row carries. Without it the # snapshot, the listing's working copy and an ordinary draft are three indistinguishable pages — # and the snapshot's lone marketplace control invites republishing immutable content as a listing @@ -230,6 +363,21 @@ def show_for(target_course, target_assessment) expect(response.parsed_body['marketplaceUpdate']).to be_nil end + + it 'dates a container snapshot chip by publish date, with no ordinal' do + container_course = create(:course, preview: true) + snapshot = create(:assessment, course: container_course) + listing.current_version.update!(assessment: snapshot) + controller_sign_in(controller, admin) + + get :index, as: :json, params: { course_id: container_course } + + row = response.parsed_body['assessments'].find { |a| a['id'] == snapshot.id } + expect(row['marketplaceVersion']).to have_key('publishedAt') + expect(row['marketplaceVersion']).not_to have_key('version') + expect(Time.zone.parse(row['marketplaceVersion']['publishedAt'])). + to be_within(1.second).of(v1_at) + end end describe 'the in-place update gate on the show payload' do From 621c34d9d067f93e04784161205a25a76fa4fb80 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 21:15:09 +0800 Subject: [PATCH 10/28] feat(marketplace): warn against editing a published snapshot A snapshot in the container course is an ordinary assessment with every management affordance live, and editing one is silently destructive: it changes what future adopters copy for a version that was never published, and stops the version's publication date describing its content. A soft guard only. Nothing is disabled, because the surface is admin-only and the escape hatch for repairing served content without minting a version is deliberate. The banner names the risk and links straight at the source assessment to edit instead, on that assessment's own host since it may live on another instance. --- .../assessment/assessments_controller.rb | 49 ++++++++++- .../assessment/assessments/show.json.jbuilder | 5 ++ .../AssessmentShow/AssessmentShowPage.tsx | 5 ++ .../MarketplaceSnapshotBanner.tsx | 58 +++++++++++++ .../__test__/AssessmentShowPage.test.tsx | 28 +++++++ .../MarketplaceSnapshotBanner.test.tsx | 81 +++++++++++++++++++ .../bundles/course/assessment/translations.ts | 14 ++++ .../types/course/assessment/assessments.ts | 7 ++ .../assessments_marketplace_spec.rb | 76 ++++++++++++++++- 9 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceSnapshotBanner.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index f3756634a39..b8d21d9e6d8 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -280,9 +280,56 @@ def marketplace_version_labels # The single-assessment reading of the same labels, for `show`. Nil for a container assessment that # is neither a snapshot nor a listing's working copy — one authored in the container directly. # + # A snapshot additionally carries where to edit the content it froze. Merged here rather than in + # `labels_for_assessments`, which the index shares and has no use for the field. + # # @return [Hash, nil] def marketplace_version_label - Course::Assessment::Marketplace::ListingVersion.labels_for_assessments([@assessment.id])[@assessment.id] + label = Course::Assessment::Marketplace::ListingVersion. + labels_for_assessments([@assessment.id])[@assessment.id] + return nil if label.nil? + # Skipped for the working copy: the source assessment is this page. + return label if label[:published_at].nil? + + label.merge(source_assessment_url: source_assessment_url(label[:listing_id])) + end + + # Absolute, and carrying the source assessment's own host: a course id only resolves on its + # instance's host, and a listing's source lives on whichever instance published it. Nil for an + # orphaned listing, whose source was deleted and whose rebuild has not landed. + # + # `without_tenant` is load-bearing. Viewing a container snapshot means the request is tenanted to + # the container's instance, so a source published elsewhere has its course filtered out and + # `assessment.course` returns nil rather than raising — dropping the link in the common case. + # + # @param [Integer] listing_id + # @return [String, nil] + def source_assessment_url(listing_id) + ActsAsTenant.without_tenant do + listing = Course::Assessment::Marketplace::Listing. + includes(authoring_assessment: { lesson_plan_item: { course: :instance } }). + find_by(id: listing_id) + assessment = listing&.authoring_assessment + next nil if assessment.nil? + + course_assessment_url(assessment.course_id, assessment, + **host_options(assessment.course.instance.host)) + end + end + + # `Instance#host` carries the port the app is publicly served on, and that port must be named + # explicitly: a controller's `url_options` always supplies `port: request.optional_port`, and Rails + # reads a port out of `host:` only when no `:port` key is present, so passing the host alone swaps + # in the port the request reached Rails on. + # + # Duplicated from System::Admin::MarketplaceListingsController, which sits above this commit in the + # stack; extract once the marketplace stack lands. + # + # @param [String] host an instance host, optionally carrying a port + # @return [Hash] the `host:`/`port:` options for a url on that instance + def host_options(host) + name, port = host.split(':', 2) + { host: name, port: port } end def load_assessment_submission_counts diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index 5950a256ce7..38dbcda2a30 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -98,6 +98,11 @@ if @marketplace_version json.source @marketplace_version[:source] json.latest @marketplace_version[:latest] json.listed @marketplace_version[:listed] + # Snapshot only, so the key's absence tells the client this is the listing's working copy. Null + # when the listing is orphaned. Absent from the index badge, which has no use for it. + if @marketplace_version.key?(:source_assessment_url) + json.sourceAssessmentUrl @marketplace_version[:source_assessment_url] + end end end diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx index a9b59b3fe3c..ac23a259adc 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx @@ -27,6 +27,7 @@ import MarketplaceVersionChip from '../AssessmentsIndex/MarketplaceVersionChip'; import AssessmentDetails from './AssessmentDetails'; import AssessmentShowHeader from './AssessmentShowHeader'; import GenerateQuestionMenu from './GenerateQuestionMenu'; +import MarketplaceSnapshotBanner from './MarketplaceSnapshotBanner'; import MarketplaceUpdateBanner from './MarketplaceUpdateBanner'; import NewQuestionMenu from './NewQuestionMenu'; import QuestionsManager from './QuestionsManager'; @@ -85,6 +86,10 @@ const AssessmentShowPage = (props: AssessmentShowPageProps): JSX.Element => { )} + {/* Before the adopter banner: the two cannot co-occur, since a container snapshot is never an + adoption, but "this object is frozen" would outrank "a newer version exists". */} + + { + const { t } = useTranslation(); + + // An assessment the marketplace does not own carries no version at all. + if (!version) return null; + // A null vintage is the listing's working copy, which is exactly what an admin is meant to edit. + // Kept as its own guard rather than an optional chain: `version?.publishedAt === null` is false + // for an absent version, so the two conditions do not collapse into one. + if (version.publishedAt === null) return null; + + return ( + + + {t(translations.marketplaceSnapshotWarning)} + + + {/* `href`, not `to`: `to` routes through ReactRouterLink and would treat a cross-instance + absolute url as an in-app path. `external` also renders the ArrowOutward affordance. */} + {version.sourceAssessmentUrl ? ( + + {t(translations.marketplaceSnapshotSourceLink)} + + ) : ( + + {t(translations.marketplaceSnapshotSourceMissing)} + + )} + + ); +}; + +export default MarketplaceSnapshotBanner; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx index 31937d7c3bd..dce462b8c97 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx @@ -71,6 +71,11 @@ describe('', () => { expect(await page.findByText('Source Assessment')).toBeVisible(); expect(page.queryByText('Live')).not.toBeInTheDocument(); + // Editing the working copy is the point, so it must not be warned against. The chip assertion + // above is the async gate: once it is up, the banner has had its chance to render. + expect( + page.queryByText(/frozen at its publication date/), + ).not.toBeInTheDocument(); }); it('shows no marketplace chip outside the container', async () => { @@ -79,5 +84,28 @@ describe('', () => { expect(await page.findByText(baseAssessment.title)).toBeVisible(); expect(page.queryByText(/2026/)).not.toBeInTheDocument(); expect(page.queryByText('Source Assessment')).not.toBeInTheDocument(); + expect( + page.queryByText(/frozen at its publication date/), + ).not.toBeInTheDocument(); + }); + + // The show page is the only route to a snapshot, so the warning has to reach it through the page, + // not merely render in isolation. + it('warns on the page when the assessment is a published snapshot', async () => { + const page = renderWith({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: true, + listed: true, + sourceAssessmentUrl: 'http://origin.lvh.me/courses/3/assessments/9', + }); + + expect( + await page.findByText(/frozen at its publication date/), + ).toBeInTheDocument(); + expect( + page.getByRole('link', { name: 'Open source assessment' }), + ).toHaveAttribute('href', 'http://origin.lvh.me/courses/3/assessments/9'); }); }); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx new file mode 100644 index 00000000000..7f261798028 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx @@ -0,0 +1,81 @@ +import { render, RenderResult } from 'test-utils'; +import { MarketplaceVersionData } from 'types/course/assessment/assessments'; + +import MarketplaceSnapshotBanner from '../MarketplaceSnapshotBanner'; + +/** + * `NotificationPopup` mounts inside `I18nProvider` (see `Providers`), so its presence is the signal + * that the provider resolved its messages and the banner has had its chance to render. Without this + * gate an absence assertion passes vacuously, by running before anything has mounted at all. + */ +const settle = (page: RenderResult): Promise => + page.findByLabelText(/Notifications/); + +const SOURCE_URL = 'http://origin.lvh.me/courses/3/assessments/9'; + +const snapshot = ( + overrides: Partial = {}, +): MarketplaceVersionData => ({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: true, + listed: true, + sourceAssessmentUrl: SOURCE_URL, + ...overrides, +}); + +describe('', () => { + it('warns that a snapshot is frozen and sends the admin to the source assessment', async () => { + const page = render(); + + expect( + await page.findByText(/frozen at its publication date/), + ).toBeInTheDocument(); + // `role="alert"` is what the two absence assertions below query on, so pin it here. + expect(page.getByRole('alert')).toBeInTheDocument(); + + // `href`, not `to`: a cross-instance absolute url must not be routed as an in-app path. + const link = page.getByRole('link', { name: 'Open source assessment' }); + expect(link).toHaveAttribute('href', SOURCE_URL); + }); + + // An orphaned listing has no source to open yet. Saying so beats a dead or absent link. + it('explains the missing source instead of linking when the listing is orphaned', async () => { + const page = render( + , + ); + + expect( + await page.findByText(/one is being rebuilt from this version/), + ).toBeInTheDocument(); + expect(page.queryByRole('link')).not.toBeInTheDocument(); + }); + + // `publishedAt === null` is the working copy, which is exactly what an admin is meant to edit — + // the same discriminator MarketplaceVersionChip uses to label it "Source Assessment". + it('renders nothing for the listing working copy', async () => { + const page = render( + , + ); + + await settle(page); + + expect(page.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders nothing for an assessment the marketplace does not own', async () => { + const page = render(); + + await settle(page); + + expect(page.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 37e70a92492..92c53862491 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -277,6 +277,20 @@ const translations = defineMessages({ id: 'course.assessments.index.marketplaceSearchText', defaultMessage: 'Search by assessment title or source course', }, + marketplaceSnapshotWarning: { + id: 'course.assessments.show.marketplaceSnapshotWarning', + defaultMessage: + 'This is a published version, frozen at its publication date. Editing it silently changes what courses copy from the marketplace without publishing a new version. Make changes on the source assessment instead, then publish a new version.', + }, + marketplaceSnapshotSourceLink: { + id: 'course.assessments.show.marketplaceSnapshotSourceLink', + defaultMessage: 'Open source assessment', + }, + marketplaceSnapshotSourceMissing: { + id: 'course.assessments.show.marketplaceSnapshotSourceMissing', + defaultMessage: + 'This listing has no source assessment right now — one is being rebuilt from this version.', + }, requirements: { id: 'course.assessment.show.requirements', defaultMessage: 'Requirements', diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 0e901c93d61..669553a9522 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -48,6 +48,13 @@ export interface MarketplaceVersionData { * being served from merely the newest one. True for a published orphan, which still serves. */ listed: boolean; + /** + * Where to edit the content this snapshot froze. Present only on the show page, and only for a + * snapshot — on the working copy the source assessment is the page you are already on. Null when + * the listing is orphaned and its rebuilt copy has not landed. Absolute, because the source may + * live on another instance. + */ + sourceAssessmentUrl?: string | null; } export interface AssessmentListData extends AssessmentActionsData { diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index cfcc0ce4ba6..7e4ff4680da 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -247,7 +247,7 @@ def show_for(target_course, target_assessment) label = response.parsed_body['marketplaceVersion'] expect(label.keys).to contain_exactly('listingId', 'publishedAt', 'source', 'latest', - 'listed') + 'listed', 'sourceAssessmentUrl') expect(label['listingId']).to eq(listing.id) expect(label['source']).to eq('MP Allowlist Source Course') expect(label['latest']).to be(true) @@ -264,6 +264,14 @@ def show_for(target_course, target_assessment) expect(label['latest']).to be(false) end + # The source assessment IS this page, so there is nothing to link to. Key absent, not null, + # so the banner's trigger never has to special-case it. + it 'omits the source link on the working copy, which is the page itself' do + show_for(container, working_copy) + + expect(response.parsed_body['marketplaceVersion']).not_to have_key('sourceAssessmentUrl') + end + it 'withholds publishing from a snapshot, which is already an existing listing content' do show_for(container, snapshot) @@ -299,6 +307,72 @@ def show_for(target_course, target_assessment) expect(response.parsed_body).not_to have_key('marketplaceVersion') end + + # The one field the index badge never carries. A snapshot is frozen, so the only useful + # action is to go edit the assessment it was cut from. + it 'points a snapshot at the assessment it was published from' do + show_for(container, snapshot) + + expect(response.parsed_body['marketplaceVersion']['sourceAssessmentUrl']). + to eq("http://#{instance.host}/courses/#{working_copy.course_id}/" \ + "assessments/#{working_copy.id}") + end + + # `host_options` exists for this: a controller's `url_options` always supplies the port the + # request arrived on, which behind any proxy is not the port the app is served on. + it 'builds the link on the instance host, not the port the request arrived on' do + request.host = 'localhost:3999' + + show_for(container, snapshot) + + expect(response.parsed_body['marketplaceVersion']['sourceAssessmentUrl']). + to start_with("http://#{instance.host}/") + end + + # The regression `without_tenant` exists for. Viewing a container snapshot means the request + # is tenanted to the container's instance, so a tenant-scoped `Course` lookup on a source + # published from elsewhere returns nil rather than raising - dropping the link silently. + it 'resolves a source assessment published from another instance' do + origin_instance = create(:instance) + origin_course = ActsAsTenant.with_tenant(origin_instance) { create(:course) } + origin_assessment = ActsAsTenant.with_tenant(origin_instance) do + create(:assessment, course: origin_course) + end + cross_listing = create(:course_assessment_marketplace_listing, + authoring_assessment: origin_assessment, + publisher: create(:user)) + cross_snapshot = create(:assessment, course: container) + create(:course_assessment_marketplace_listing_version, + listing: cross_listing, assessment: cross_snapshot, + published_at: 2.days.ago.change(usec: 0), + published_by: cross_listing.publisher). + tap { |cut| cross_listing.update!(current_version: cut) } + + show_for(container, cross_snapshot) + + expect(response.parsed_body['marketplaceVersion']['sourceAssessmentUrl']). + to eq("http://#{origin_instance.host}/courses/#{origin_course.id}/" \ + "assessments/#{origin_assessment.id}") + end + + # An orphaned listing has nothing to link at until its rebuild lands. The key is still + # emitted so the client can tell "no source" from "not a snapshot". + it 'emits a null link for an orphaned listing, whose source was deleted' do + orphan_listing = create(:course_assessment_marketplace_listing) + orphan_snapshot = create(:assessment, course: container) + create(:course_assessment_marketplace_listing_version, + listing: orphan_listing, assessment: orphan_snapshot, + published_at: 4.days.ago.change(usec: 0), + published_by: orphan_listing.publisher). + tap { |cut| orphan_listing.update!(current_version: cut) } + orphan_listing.update!(authoring_assessment: nil) + + show_for(container, orphan_snapshot) + + label = response.parsed_body['marketplaceVersion'] + expect(label).to have_key('sourceAssessmentUrl') + expect(label['sourceAssessmentUrl']).to be_nil + end end # Previewers are enrolled into the container as managers. The context is admin-only navigation, From 7f5a444cb97f88489db40db2ff6854ab8670043b Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 20:31:45 +0800 Subject: [PATCH 11/28] feat(marketplace): admin API for listing and version management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every listing across instances, with its version history, adoption list and source provenance, plus the actions only an admin has: unlist, re-list, rebuild the authoring copy, and permanently delete a listing that is off the marketplace. The whole read path runs tenant-free — listings span instances while their snapshots live in the preview container — and links to a source assessment are absolute, since a course id only resolves on its own instance's host. --- .../admin/marketplace_listings_controller.rb | 191 +++++ .../courses/_course_list_data.json.jbuilder | 4 + .../marketplace_listings/index.json.jbuilder | 23 + .../marketplace_listings/show.json.jbuilder | 35 + config/routes.rb | 7 + .../system/admin/courses_controller_spec.rb | 30 + .../marketplace_listings_controller_spec.rb | 684 ++++++++++++++++++ 7 files changed, 974 insertions(+) create mode 100644 app/controllers/system/admin/marketplace_listings_controller.rb create mode 100644 app/views/system/admin/marketplace_listings/index.json.jbuilder create mode 100644 app/views/system/admin/marketplace_listings/show.json.jbuilder create mode 100644 spec/controllers/system/admin/marketplace_listings_controller_spec.rb diff --git a/app/controllers/system/admin/marketplace_listings_controller.rb b/app/controllers/system/admin/marketplace_listings_controller.rb new file mode 100644 index 00000000000..a337e66284b --- /dev/null +++ b/app/controllers/system/admin/marketplace_listings_controller.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true +# System-admin view of every marketplace listing — what version is served, how many courses adopted +# it, and whether its source still exists — plus the maintenance actions on a listing off the +# marketplace: restore a source assessment (orphaned only), or delete it permanently (design §5.3). +# +# `System::Admin::Controller` applies `before_action :authorize_admin` (`authorize!(:manage, :all)`), +# which is the entire authorization story here. An ability check on the listing would not do: CanCan's +# `:manage` wildcard subsumes every custom action, and course managers hold a blanket +# `can :manage, Course`, so these must stay behind `:manage, :all`. +class System::Admin::MarketplaceListingsController < System::Admin::Controller + def index + @listings = Course::Assessment::Marketplace::Listing.for_admin_index + @adoption_counts = Course::Assessment::Marketplace::Adoption. + where(listing_id: @listings.map(&:id)).group(:listing_id). + distinct.count(:destination_course_id) + @authoring_urls = authoring_urls(@listings) + end + + # The per-listing provenance + history page (design §4). Read-only: every mutation stays on the + # index. This is the ONLY index into the container course — publishing copies the assessment title + # verbatim into a single shared tab, so version identity exists nowhere but the join table. + def show + @listing = find_listing + @versions = @listing.versions.ordered.includes(:assessment, :published_by).to_a + @adoptions = ActsAsTenant.without_tenant do + @listing.adoptions.includes(destination_course: :instance).order(:created_at).to_a + end + @snapshot_urls = snapshot_urls(@versions) + # The one entrance to the copy an admin edits. The index row reaches it from the Actions column; + # this page had no route to it at all, which for a rebuilt listing means no route to the only + # editable assessment it has. + @authoring_url = authoring_urls([@listing])[@listing.id] + end + + # Duplicates the listing's latest snapshot into the marketplace's own container course as a new, + # editable assessment and makes it the authoring copy, so "Publish new version" works again. There + # is no destination to choose: the container is the only correct one. Asynchronous — assessment + # duplication is the same heavy path adopters go through — so the client polls the returned `jobUrl`. + def restore_authoring + listing = find_listing + error = restore_rejection(listing) + return render json: { errors: [error] }, status: :unprocessable_content if error + + job = Course::Assessment::Marketplace::RestoreAuthoringJob. + perform_later(listing.id, current_user: current_user).job + render partial: 'jobs/submitted', locals: { job: job } + end + + # Takes a listing off the marketplace, or puts it back — the reversible step, and the one an admin + # has to take before `destroy` will accept a listing at all. + # + # It duplicates the course-side `MarketplaceListingsController#destroy` rather than reusing it + # because that action resolves the listing through its authoring assessment, which an orphaned + # listing has none of. Re-listing does not go through `PublishService.publish` for the same reason, + # and because re-listing must not cut a version — a round trip would mint a vintage nobody published. + # + # `published` is the ONLY writable attribute. Everything else on a listing is either provenance, + # which is historical fact, or version state, which the publish path owns. + def update + listing = find_listing + error = list_rejection(listing, published_param) + return render json: { errors: [error] }, status: :unprocessable_content if error + + listing.update!(published: published_param) + head :ok + end + + # PERMANENT deletion, not unlisting. See Course::Assessment::Marketplace::PurgeService for why it + # is restricted to listings that are off the marketplace — orphaned or unlisted. + def destroy + listing = find_listing + return render json: { errors: [purge_rejection(listing)] }, status: :unprocessable_content unless + listing.purgeable? + + Course::Assessment::Marketplace::PurgeService.purge!(listing) + head :ok + end + + # The single canonicalisation both the version rows and the adoption rows go through. + # @param [ActiveSupport::TimeWithZone, nil] published_at + # @return [String, nil] + def self.snapshot_key(published_at) + published_at&.utc&.iso8601(6) + end + + private + + # Listings span every instance while their snapshots live in the container's, so every lookup here + # is tenant-free — the same reason `.for_admin_index` is. + def find_listing + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::Listing.find(params[:id]) + end + end + + # `params[:published]` arrives as a JSON boolean from our own client and as a string from anything + # else, so it goes through the same cast the settings components use. + # @return [Boolean] + def published_param + ActiveRecord::Type::Boolean.new.cast(params[:published]) + end + + # Unlisting is always allowed — it is the reversible step, and it is what an admin reaches for when + # something is wrong with a listing. Only LISTING is gated: a listing with no version has nothing to + # serve, so putting it on the marketplace would advertise an empty shelf. An orphan, by contrast, is + # perfectly listable — it goes on serving the snapshot it already holds. + # + # @return [String, nil] the reason the change is refused, or nil if it may proceed + def list_rejection(listing, published) + return nil unless published + return 'This listing has no published version to serve.' if listing.current_version_id.nil? + + nil + end + + # @return [String, nil] the reason the restore is refused, or nil if it may proceed + def restore_rejection(listing) + return 'Only an orphaned listing can have its source assessment rebuilt.' unless listing.orphaned? + return 'This listing has no published version to restore from.' if listing.current_version.nil? + + nil + end + + # @return [String] the reason the deletion is refused + def purge_rejection(_listing) + 'A published listing cannot be permanently deleted. Unlist it first.' + end + + # tenant-free because the container sits in an instance that is never the caller's. + # + # Keyed by a CANONICALISED timestamp string rather than a raw Time: hash lookup is by value + # equality, and any precision difference between two Time objects silently misses. Both sides read + # `datetime` columns of the same precision written from the same value, so the strings match + # exactly; a mismatch degrades to a null link rather than an error. + # + # @return [Hash{String => String}] canonicalised publish date => absolute snapshot url + def snapshot_urls(versions) + return {} if versions.empty? + + ActsAsTenant.without_tenant do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + host = container.instance.host + + versions.each_with_object({}) do |version, urls| + next if version.assessment.nil? + + urls[self.class.snapshot_key(version.published_at)] = + course_assessment_url(version.assessment.course_id, version.assessment, **host_options(host)) + end + end + end + + # Precomputed here rather than in the view (app/CLAUDE.md keeps logic out of jbuilder), and keyed + # off `course_id` rather than the `course` association for the path segment: the listings are + # loaded without a tenant, where the acting-as `course` association does not resolve and the path + # helper would receive nil. + # + # An absolute URL carrying the source course's own host, not a path: `Course` is + # `acts_as_tenant :instance`, so a course id only resolves on its instance's host and a relative + # path 404s for every listing published from another instance. The loop runs tenant-free so the + # cross-instance `course` (preloaded by `.for_admin_index`) resolves at all. + # + # @return [Hash{Integer => String}] listing id => absolute authoring assessment url + def authoring_urls(listings) + ActsAsTenant.without_tenant do + listings.each_with_object({}) do |listing, urls| + assessment = listing.authoring_assessment + next if assessment.nil? + + urls[listing.id] = course_assessment_url(assessment.course_id, assessment, + **host_options(assessment.course.instance.host)) + end + end + end + + # `Instance#host` carries the port the app is publicly served on, and that port must be named + # explicitly: a controller's `url_options` always supplies `port: request.optional_port`, and Rails + # reads a port out of `host:` only when no `:port` key is present — so passing the host alone + # silently swaps in the port the request reached Rails on. + # + # The two differ whenever a proxy sits in front, i.e. every development setup, and the link then + # names a port the browser cannot reach. A host with no port yields `port: nil`, which is what + # production wants. Jobs and mailers escape this: no request, hence no `:port` key. + # + # @param [String] host an instance host, optionally carrying a port + # @return [Hash] the `host:`/`port:` options for a url on that instance + def host_options(host) + name, port = host.split(':', 2) + { host: name, port: port } + end +end diff --git a/app/views/system/admin/courses/_course_list_data.json.jbuilder b/app/views/system/admin/courses/_course_list_data.json.jbuilder index 91cfaf3f2b9..bb9a39aa53b 100644 --- a/app/views/system/admin/courses/_course_list_data.json.jbuilder +++ b/app/views/system/admin/courses/_course_list_data.json.jbuilder @@ -4,6 +4,10 @@ json.title course.title json.createdAt course.created_at json.activeUserCount course.active_user_count json.userCount course.user_count +# The marketplace preview container is a `preview: true` course. It is exposed here so course pickers +# can leave it out of their options; keying off the flag rather than a host or instance id is what +# Course::Assessment::Marketplace::PreviewContainerService guarantees. +json.preview course.preview json.instance do json.id course.instance.id json.name course.instance.name diff --git a/app/views/system/admin/marketplace_listings/index.json.jbuilder b/app/views/system/admin/marketplace_listings/index.json.jbuilder new file mode 100644 index 00000000000..30828021bd2 --- /dev/null +++ b/app/views/system/admin/marketplace_listings/index.json.jbuilder @@ -0,0 +1,23 @@ +# frozen_string_literal: true +json.listings @listings do |listing| + json.id listing.id + json.title listing.current_version&.assessment&.title + json.currentVersionPublishedAt listing.current_version&.published_at + json.lastPublishedAt listing.last_published_at + json.adoptions(@adoption_counts[listing.id] || 0) + json.sourceCourseId listing.source_course_id + json.sourceCourseName listing.source_course_name + # Two instances can each have a course called "CS1010", and a course id only resolves on its own + # instance's host — hence both the name (to tell them apart) and the host (to link at all). + json.sourceInstanceName listing.source_instance&.name + json.sourceInstanceHost listing.source_instance&.host + json.state listing.admin_state + # Orthogonal to `state`: WHERE the authoring copy lives, not whether the listing is on the + # marketplace. The provenance fields above keep naming the origin course even after a rebuild. + json.marketplaceHosted listing.marketplace_hosted? + # Deletion facts, separate from `state`/visibility: a rebuilt listing can be published AND have a + # deleted origin at the same time. + json.sourceAssessmentDeleted listing.source_assessment_deleted? + json.sourceCourseDeleted listing.source_course_deleted? + json.authoringAssessmentUrl @authoring_urls[listing.id] +end diff --git a/app/views/system/admin/marketplace_listings/show.json.jbuilder b/app/views/system/admin/marketplace_listings/show.json.jbuilder new file mode 100644 index 00000000000..3d62eca55d4 --- /dev/null +++ b/app/views/system/admin/marketplace_listings/show.json.jbuilder @@ -0,0 +1,35 @@ +# frozen_string_literal: true +json.id @listing.id +# The SNAPSHOT title, matching the index: the marketplace shows what an adopter would actually get. +json.title @listing.current_version&.assessment&.title +json.currentVersionPublishedAt @listing.current_version&.published_at +json.state @listing.admin_state +json.marketplaceHosted @listing.marketplace_hosted? +# Deletion facts, separate from `state`/visibility: a rebuilt listing can be published AND have a +# deleted origin at the same time. +json.sourceAssessmentDeleted @listing.source_assessment_deleted? +json.sourceCourseDeleted @listing.source_course_deleted? +json.authoringAssessmentUrl @authoring_url +json.sourceCourseId @listing.source_course_id +json.sourceCourseName @listing.source_course_name +json.sourceInstanceName @listing.source_instance&.name +json.sourceInstanceHost @listing.source_instance&.host + +json.versions @versions do |version| + json.publishedAt version.published_at + json.publisherName version.published_by&.name + json.isCurrent version.id == @listing.current_version_id + json.snapshotUrl @snapshot_urls[System::Admin::MarketplaceListingsController.snapshot_key(version.published_at)] +end + +json.adoptions @adoptions do |adoption| + json.id adoption.id + json.destinationCourseId adoption.destination_course_id + json.destinationCourseName adoption.destination_course&.title + # A course id only resolves on its own instance's host, and adopters span instances. + json.destinationCourseHost adoption.destination_course&.instance&.host + json.adoptedVersionAt adoption.adopted_version_at + json.adoptedAt adoption.created_at + snapshot_key = System::Admin::MarketplaceListingsController.snapshot_key(adoption.adopted_version_at) + json.snapshotUrl @snapshot_urls[snapshot_key] +end diff --git a/config/routes.rb b/config/routes.rb index 9ce981134d6..005b2ac8b9d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -116,6 +116,13 @@ end get 'marketplace_access' => 'marketplace_access#index' resources :marketplace_access_blocks, only: [:create, :destroy] + # `destroy` here is a permanent purge of an orphaned listing, not the reversible unlist that the + # course-side `marketplace_listing#destroy` performs. `update` is that reversible unlist — + # admin-side because the course-side one hangs off the authoring assessment, which an orphaned + # listing no longer has. + resources :marketplace_listings, only: [:index, :show, :update, :destroy] do + post :restore_authoring, on: :member + end resources :instances, only: [:index, :create, :update, :destroy] resources :users, only: [:index, :update, :destroy] resources :courses, only: [:index, :destroy] diff --git a/spec/controllers/system/admin/courses_controller_spec.rb b/spec/controllers/system/admin/courses_controller_spec.rb index b1a84b17bec..449cd0c8b4b 100644 --- a/spec/controllers/system/admin/courses_controller_spec.rb +++ b/spec/controllers/system/admin/courses_controller_spec.rb @@ -31,6 +31,36 @@ end end + # The hidden marketplace preview container is a `preview: true` course and the system-admin index + # is cross-instance, so it appears here like any other course. Course pickers key off this flag to + # leave it out of their options (never off a host or instance id), so the payload must carry it. + describe '#index payload' do + render_views + + let!(:container) do + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + let!(:ordinary_course) { create(:course) } + + before { controller_sign_in(controller, admin) } + + def row_for(course) + response.parsed_body['courses'].find { |c| c['id'] == course.id } + end + + it 'flags the preview container and only the preview container' do + get :index, as: :json, params: { search: container.title } + + expect(row_for(container)['preview']).to be(true) + + get :index, as: :json, params: { search: ordinary_course.title } + + expect(row_for(ordinary_course)['preview']).to be(false) + end + end + describe '#destroy' do let!(:course_to_delete) { create(:course) } let!(:course_stub) do diff --git a/spec/controllers/system/admin/marketplace_listings_controller_spec.rb b/spec/controllers/system/admin/marketplace_listings_controller_spec.rb new file mode 100644 index 00000000000..504c2347e24 --- /dev/null +++ b/spec/controllers/system/admin/marketplace_listings_controller_spec.rb @@ -0,0 +1,684 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceListingsController, type: :controller do + render_views + + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + describe 'GET #index' do + subject { get :index, format: :json } + + before { controller_sign_in(controller, admin) } + + def row_for(listing) + response.parsed_body['listings'].find { |l| l['id'] == listing.id } + end + + it 'lists a published listing with its published vintage, provenance and adoption count' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + create(:course_assessment_marketplace_adoption, listing: listing) + + subject + + row = row_for(listing) + expect(row).to have_key('currentVersionPublishedAt') + expect(row).not_to have_key('currentVersion') + expect(Time.zone.parse(row['currentVersionPublishedAt'])). + to be_within(1.second).of(listing.current_version.published_at) + expect(row['adoptions']).to eq(1) + expect(row['sourceCourseName']).to eq(course.title) + expect(row['state']).to eq('published') + end + + it 'carries the source instance, so two same-named courses can be told apart' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + + subject + + row = row_for(listing) + expect(row['sourceInstanceName']).to eq(instance.name) + expect(row['sourceInstanceHost']).to eq(instance.host) + end + + # The marketplace is cross-instance while `Course` is `acts_as_tenant :instance`, so a course id + # resolves ONLY on its own instance's host. A path (or the admin's own host) 404s for every + # listing published from elsewhere, which is why the url is absolute and carries that host. + context 'when the source course lives in another instance' do + let(:other_instance) { create(:instance) } + let(:other_course) { ActsAsTenant.with_tenant(other_instance) { create(:course) } } + let(:other_assessment) do + ActsAsTenant.with_tenant(other_instance) { create(:assessment, course: other_course) } + end + + it 'builds the authoring url on that instance host' do + listing = Course::Assessment::Marketplace::PublishService.publish(other_assessment, admin) + + subject + + # Asserted as prefix + suffix rather than one literal: the test env's + # `default_url_options` injects a port that production does not have. + url = row_for(listing)['authoringAssessmentUrl'] + expect(url).to start_with("http://#{other_instance.host}") + expect(url). + to end_with("/courses/#{other_course.id}/assessments/#{other_assessment.id}") + end + + it 'reports that instance as the source, not the admin’s own' do + listing = Course::Assessment::Marketplace::PublishService.publish(other_assessment, admin) + + subject + + row = row_for(listing) + expect(row['sourceInstanceName']).to eq(other_instance.name) + expect(row['sourceInstanceHost']).to eq(other_instance.host) + expect(row['sourceInstanceHost']).not_to eq(instance.host) + end + end + + # `Instance#host` carries the port the app is publicly served on, which is not the port the + # request reached Rails on whenever a proxy sits in front — i.e. every development setup. A + # controller's `url_options` always supplies `port: request.optional_port`, and Rails reads a + # port out of `host:` only when no `:port` key is present, so the request's port silently won. + # + # Reproduced by moving the request off the instance's port, which is what the proxy does. + it 'keeps the port carried by the instance host, not the one the request arrived on' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + request.host = 'localhost:3999' + + subject + + expect(row_for(listing)['authoringAssessmentUrl']).to start_with("http://#{instance.host}/") + end + + it 'reports no instance for a listing orphaned before the instance was ever recorded' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update_columns(authoring_assessment_id: nil, source_course_id: nil, + source_instance_id: nil) + + subject + + row = row_for(listing) + expect(row['sourceInstanceName']).to be_nil + expect(row['sourceInstanceHost']).to be_nil + end + + it 'serves the snapshot title, not the authoring title' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + assessment.update!(title: 'Renamed after publish') + + subject + + expect(row_for(listing)['title']).not_to eq('Renamed after publish') + end + + it 'flags an unlisted listing' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update!(published: false) + + subject + + expect(row_for(listing)['state']).to eq('unlisted') + end + + it 'flags a listing orphaned by an assessment deletion and offers no authoring url' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update!(authoring_assessment: nil) + + subject + + row = row_for(listing) + expect(row['state']).to eq('published') + expect(row['sourceAssessmentDeleted']).to be(true) + expect(row['sourceCourseDeleted']).to be(false) + expect(row['authoringAssessmentUrl']).to be_nil + end + + # Reported alongside `state` rather than folded into it: the two answer different questions, and + # the provenance columns keep naming the ORIGIN course even after a rebuild, so this is the only + # thing in the payload that says where the copy an admin would edit actually lives. + it 'marks a listing whose authoring copy lives in the container as marketplace-hosted' do + container = create(:course, preview: true) + listing = Course::Assessment::Marketplace::PublishService. + publish(create(:assessment, course: container), admin) + + subject + + expect(row_for(listing)['marketplaceHosted']).to be(true) + expect(row_for(listing)['state']).to eq('published') + end + + it 'does not mark a listing whose authoring copy is in an ordinary course' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + + subject + + expect(row_for(listing)['marketplaceHosted']).to be(false) + end + + # The whole origin course went, so the FK nullified `source_course_id` too. Identifiable + # provenance differs from a plain assessment deletion, which is why it is its own boolean. + it 'flags a listing orphaned by a course deletion' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update!(authoring_assessment: nil, source_course: nil) + + subject + + row = row_for(listing) + expect(row['state']).to eq('published') + expect(row['sourceAssessmentDeleted']).to be(true) + expect(row['sourceCourseDeleted']).to be(true) + expect(row['sourceCourseName']).to eq(course.title) + end + + it 'reports no deletion facts for a healthy listing with an intact authoring copy' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + + subject + + row = row_for(listing) + expect(row['sourceAssessmentDeleted']).to be(false) + expect(row['sourceCourseDeleted']).to be(false) + end + + it 'only ever reports published or unlisted as the state' do + listed = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + unlisted = Course::Assessment::Marketplace::PublishService. + publish(create(:assessment, course: course), admin) + unlisted.update!(published: false) + + subject + + expect(row_for(listed)['state']).to eq('published') + expect(row_for(unlisted)['state']).to eq('unlisted') + expect(response.parsed_body['listings'].map { |l| l['state'] }.uniq.sort). + to eq(%w[published unlisted]) + end + end + + describe 'POST #restore_authoring' do + # `have_enqueued_job` requires the :test adapter; the test env defaults to :background_thread. + with_active_job_queue_adapter(:test) do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, course: course) } + + before { controller_sign_in(controller, admin) } + + def restore(overrides = {}) + post :restore_authoring, params: { id: listing.id, format: :json }.merge(overrides) + end + + def orphan!(target = listing) + target.authoring_assessment.destroy! + target.reload + end + + context 'when the listing is orphaned and versioned' do + before { orphan! } + + it 'enqueues the restore job and returns a pollable jobUrl' do + expect { restore }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing.id, current_user: admin) + expect(response.parsed_body['jobUrl']).to be_present + end + + # No destination is accepted any more: the container is the only destination, so a stray + # param must not be able to redirect the copy into somebody's live course. + it 'ignores a destination_course_id param entirely' do + other_course = create(:course) + + expect { restore(destination_course_id: other_course.id) }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing.id, current_user: admin) + end + end + + it 'rejects a listing that still has its authoring copy' do + expect { restore }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/Only an orphaned listing/) + end + + it 'rejects an orphaned listing with no version to restore from' do + listing = create(:course_assessment_marketplace_listing, course: course) + orphan!(listing) + + expect { restore(id: listing.id) }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/no published version/) + end + end + end + + describe 'DELETE #destroy (permanent purge)' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, course: course) } + + before { controller_sign_in(controller, admin) } + + def purge + delete :destroy, params: { id: listing.id, format: :json } + end + + def orphan! + listing.authoring_assessment.destroy! + listing.reload + end + + it 'deletes an orphaned listing with no adoptions, along with its snapshots' do + snapshot = listing.current_version.assessment + orphan! + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + expect(response).to have_http_status(:ok) + end + + it 'deletes an unlisted listing with no adoptions, along with its snapshots' do + snapshot = listing.current_version.assessment + listing.update!(published: false) + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + expect(response).to have_http_status(:ok) + end + + # Unlisting is the reversible step, so it is the one an admin has to take first. + it 'refuses a published listing and says to unlist it first' do + expect { purge }. + not_to(change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/Unlist it first/) + end + + it 'deletes an unlisted listing that has been adopted, along with its adoption rows' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + listing.update!(published: false) + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + expect(response).to have_http_status(:ok) + # A purge must never reach into another course's content. + expect(duplicated_assessment.reload).to be_persisted + end + + it 'deletes an orphaned listing that has been adopted, along with its adoption rows' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + orphan! + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + expect(response).to have_http_status(:ok) + expect(duplicated_assessment.reload).to be_persisted + end + end + + # The admin-side counterpart of the course-side unlist. It exists separately because that one + # resolves the listing through its AUTHORING assessment, so it cannot reach an orphaned listing at + # all — which is precisely the listing an admin most often has to take off the marketplace. + describe 'PATCH #update (list / unlist)' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, course: course) } + + before { controller_sign_in(controller, admin) } + + def set_published(value, id: listing.id) + patch :update, params: { id: id, published: value, format: :json } + end + + it 'unlists a published listing' do + expect { set_published(false) }.to change { listing.reload.published }.from(true).to(false) + expect(response).to have_http_status(:ok) + end + + it 'lists an unlisted listing again, serving the version it already had' do + version = listing.current_version + listing.update!(published: false) + + expect { set_published(true) }.to change { listing.reload.published }.from(false).to(true) + expect(response).to have_http_status(:ok) + # Re-listing restores VISIBILITY only. It must never cut a version, or an unlist/list round + # trip would mint a vintage nobody published — same rule PublishService follows. + expect(listing.current_version).to eq(version) + expect(listing.versions.count).to eq(1) + end + + # The case the course-side unlist cannot serve at all. + it 'unlists an orphaned listing' do + listing.authoring_assessment.destroy! + + expect { set_published(false) }.to change { listing.reload.published }.from(true).to(false) + expect(response).to have_http_status(:ok) + end + + # An orphan goes on serving its snapshot, so there is nothing incoherent about it being listed. + it 'lists an orphaned listing that still holds a version' do + listing.authoring_assessment.destroy! + listing.update!(published: false) + + expect { set_published(true) }.to change { listing.reload.published }.from(false).to(true) + expect(response).to have_http_status(:ok) + end + + it 'refuses to list a listing that has never published a version' do + versionless = create(:course_assessment_marketplace_listing, course: course, published: false) + + expect { set_published(true, id: versionless.id) }. + not_to(change { versionless.reload.published }) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/no published version/) + end + + # Nothing else on a listing is the admin's to edit here: provenance is historical fact and the + # version pointer belongs to the publish path. + it 'ignores every attribute other than published' do + expect do + patch :update, params: { id: listing.id, published: false, title: 'Renamed', + source_course_name: 'Elsewhere', format: :json } + end. + not_to(change { listing.reload.source_course_name }) + expect(response).to have_http_status(:ok) + end + end + + describe 'GET #show' do + let(:listing) do + create(:course_assessment_marketplace_listing, :versioned, course: course, + source_course: course, + source_instance: instance) + end + + before { controller_sign_in(controller, admin) } + + def show + get :show, params: { id: listing.id, format: :json } + end + + it 'reports provenance, the served vintage and the assessment title' do + show + + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body['id']).to eq(listing.id) + expect(body['title']).to eq(listing.current_version.assessment.title) + expect(body).to have_key('currentVersionPublishedAt') + expect(body).not_to have_key('currentVersion') + expect(Time.zone.parse(body['currentVersionPublishedAt'])). + to be_within(1.second).of(listing.current_version.published_at) + expect(body['state']).to eq('published') + expect(body['sourceInstanceName']).to eq(instance.name) + expect(body['marketplaceHosted']).to be(false) + end + + it 'reports a container-hosted authoring copy, matching the index' do + listing.update!(authoring_assessment: create(:assessment, course: create(:course, preview: true))) + + show + + expect(response.parsed_body['marketplaceHosted']).to be(true) + end + + it 'lists every version ascending, flagging the current one' do + v1_at = listing.current_version.published_at + v2_at = v1_at + 1.day + v3_at = v1_at + 2.days + create(:course_assessment_marketplace_listing_version, + listing: listing, published_at: v3_at, published_by: admin) + create(:course_assessment_marketplace_listing_version, + listing: listing, published_at: v2_at, published_by: admin) + + show + + versions = response.parsed_body['versions'] + published_times = versions.map { |version| Time.zone.parse(version['publishedAt']) } + expect(published_times).to eq(published_times.sort) + expect(published_times).to all(be_present) + expect(published_times[0]).to be_within(1.second).of(v1_at) + expect(published_times[1]).to be_within(1.second).of(v2_at) + expect(published_times[2]).to be_within(1.second).of(v3_at) + expect(versions).to all(satisfy { |version| !version.key?('version') }) + # v1 is the current version because the :versioned trait pointed the listing at it. + expect(versions.map { |v| v['isCurrent'] }).to eq([true, false, false]) + end + + it 'names who published each version' do + create(:course_assessment_marketplace_listing_version, + listing: listing, published_at: listing.current_version.published_at + 1.day, + published_by: admin) + + show + + expect(response.parsed_body['versions'].last['publisherName']).to eq(admin.name) + end + + it 'dates v1 from the version publish date' do + published = 4.months.ago.change(usec: 0) + listing.current_version.update!(published_at: published) + + show + + expect(Time.zone.parse(response.parsed_body['versions'].first['publishedAt'])). + to be_within(1.second).of(published) + end + + it 'links each version to its snapshot on the container host' do + show + + snapshot = listing.current_version.assessment + url = response.parsed_body['versions'].first['snapshotUrl'] + expect(url).to include("/assessments/#{snapshot.id}") + expect(url).to start_with('http') + end + + # Same trap as the authoring url on the index — see the note there. + it 'keeps the port carried by the container host, not the one the request arrived on' do + container_host = ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course.instance.host + end + request.host = 'localhost:3999' + + show + + expect(response.parsed_body['versions'].first['snapshotUrl']). + to start_with("http://#{container_host}/") + end + + it 'reports adoptions with the vintage each course holds' do + adopter = create(:course) + create(:course_assessment_marketplace_adoption, listing: listing, + destination_course: adopter, + adopted_version_at: listing.current_version.published_at) + + show + + adoptions = response.parsed_body['adoptions'] + expect(adoptions.size).to eq(1) + expect(adoptions.first['destinationCourseId']).to eq(adopter.id) + expect(adoptions.first['destinationCourseName']).to eq(adopter.title) + expect(adoptions.first).to have_key('adoptedVersionAt') + expect(adoptions.first).not_to have_key('adoptedVersion') + expect(Time.zone.parse(adoptions.first['adoptedVersionAt'])). + to be_within(1.second).of(listing.current_version.published_at) + expect(adoptions.first['adoptedAt']).to be_present + end + + it 'reports destination provenance for an adoption in another instance' do + other_instance = create(:instance) + other_course = ActsAsTenant.with_tenant(other_instance) { create(:course) } + ActsAsTenant.with_tenant(other_instance) do + create(:course_assessment_marketplace_adoption, listing: listing, + destination_course: other_course, + adopted_version_at: listing.current_version.published_at) + end + + show + + adoption = response.parsed_body['adoptions'].first + expect(adoption['destinationCourseName']).to eq(other_course.title) + expect(adoption['destinationCourseHost']).to eq(other_instance.host) + end + + # Unusual but valid, so it reports as an empty list rather than a missing key. + it 'reports an empty adoptions list when nobody has adopted it' do + show + + expect(response.parsed_body['adoptions']).to eq([]) + end + + it 'still serves the full history for an orphaned listing' do + listing.authoring_assessment.destroy! + + show + + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body['state']).to eq('published') + expect(body['sourceAssessmentDeleted']).to be(true) + expect(body['versions'].size).to eq(1) + end + + # Cannot happen for a published listing, but the page must not 500 if it ever does. + it 'renders an empty history for a listing with no current version' do + unversioned = create(:course_assessment_marketplace_listing, course: course) + + get :show, params: { id: unversioned.id, format: :json } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['currentVersionPublishedAt']).to be_nil + expect(response.parsed_body).not_to have_key('currentVersion') + expect(response.parsed_body['versions']).to eq([]) + end + + # A course MANAGER, not a student: managers hold a blanket `can :manage, Course` over their own + # course, so they are the user who proves the `:manage, :all` gate is what stops this. + it 'denies a course manager' do + manager = create(:course_manager, course: course).user + controller_sign_in(controller, manager) + + expect { show }.to raise_exception(CanCan::AccessDenied) + end + end + + describe 'authorization' do + # A course MANAGER, not a student: managers hold a blanket `can :manage, Course` and + # `can :manage, Course::Assessment` over their own course, so they are the user who proves the + # `:manage, :all` gate is what stops these actions. + let(:manager) { create(:course_manager, course: course).user } + + it 'denies a non-administrator' do + controller_sign_in(controller, create(:course_manager, course: course).user) + + expect { get :index, format: :json }.to raise_exception(CanCan::AccessDenied) + end + + it 'denies a course manager the restore action' do + listing = create(:course_assessment_marketplace_listing, :versioned, course: course) + listing.authoring_assessment.destroy! + controller_sign_in(controller, manager) + + expect do + post :restore_authoring, params: { id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + it 'denies a course manager the unlist' do + listing = create(:course_assessment_marketplace_listing, :versioned, course: course) + controller_sign_in(controller, manager) + + expect do + patch :update, params: { id: listing.id, published: false, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + it 'denies a course manager the permanent delete' do + listing = create(:course_assessment_marketplace_listing, :versioned, course: course) + listing.authoring_assessment.destroy! + controller_sign_in(controller, manager) + + expect { delete :destroy, params: { id: listing.id, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + end + + describe 'version identity in the admin payloads' do + render_views + + let(:admin) { create(:administrator) } + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + + before { controller_sign_in(controller, admin) } + + it 'names the served vintage on the index, with no ordinal' do + listing + + get :index, as: :json + + row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } + expect(row).to have_key('currentVersionPublishedAt') + expect(row).not_to have_key('currentVersion') + expect(Time.zone.parse(row['currentVersionPublishedAt'])). + to be_within(1.second).of(listing.current_version.published_at) + end + + it 'names each version by publish date on the show page, with no ordinal' do + get :show, as: :json, params: { id: listing.id } + + version = response.parsed_body['versions'].first + expect(version).to have_key('publishedAt') + expect(version).not_to have_key('version') + expect(version['isCurrent']).to be(true) + end + + it 'links a version snapshot through its publish date key' do + get :show, as: :json, params: { id: listing.id } + + expect(response.parsed_body['versions'].first['snapshotUrl']).to be_present + end + + it 'names the vintage an adopter holds, with no ordinal' do + adoption = create(:course_assessment_marketplace_adoption, + listing: listing, + adopted_version_at: listing.current_version.published_at) + + get :show, as: :json, params: { id: listing.id } + + row = response.parsed_body['adoptions'].find { |a| a['id'] == adoption.id } + expect(row).to have_key('adoptedVersionAt') + expect(row).not_to have_key('adoptedVersion') + expect(Time.zone.parse(row['adoptedVersionAt'])). + to be_within(1.second).of(listing.current_version.published_at) + end + + # The snapshot map is keyed by a canonicalised timestamp string. An adoption holding exactly + # the served vintage must therefore resolve to the same snapshot the version row links to. + it 'resolves the adopter snapshot link from the same key as the version row' do + create(:course_assessment_marketplace_adoption, + listing: listing, adopted_version_at: listing.current_version.published_at) + + get :show, as: :json, params: { id: listing.id } + + body = response.parsed_body + expect(body['adoptions'].first['snapshotUrl']).to eq(body['versions'].first['snapshotUrl']) + end + + it 'leaves the snapshot link null for an adoption with an unknown vintage' do + create(:course_assessment_marketplace_adoption, + listing: listing, adopted_version_at: nil) + + get :show, as: :json, params: { id: listing.id } + + expect(response.parsed_body['adoptions'].first['snapshotUrl']).to be_nil + end + end + end +end From aef1eb3a16acee20a6e2b7fef722a2015265ac5e Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 20:31:45 +0800 Subject: [PATCH 12/28] feat(marketplace): admin UI for listing and version management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listings table, its per-listing page, and the two maintenance buttons. Actions sit in one fixed order so a given action always occupies the same slot, and delete is present on every row — disabled until the listing is unlisted — so its tooltip can state the rule rather than leaving the admin to infer it from a missing icon. --- client/app/api/system/Admin.ts | 65 + .../system/admin/admin/AdminNavigator.tsx | 9 + .../MarketplaceListingVisibilityButton.tsx | 137 ++ .../MarketplaceRestoreAuthoringButton.tsx | 144 ++ .../tables/MarketplaceListingsTable.tsx | 568 ++++++ .../admin/pages/MarketplaceListingShow.tsx | 461 +++++ .../admin/pages/MarketplaceListingsIndex.tsx | 94 + .../__test__/MarketplaceListingShow.test.tsx | 552 ++++++ .../MarketplaceListingsIndex.test.tsx | 1522 +++++++++++++++++ client/app/routers/courseless/systemAdmin.tsx | 22 + client/app/types/system/courses.ts | 2 + .../app/types/system/marketplaceListings.ts | 94 + 12 files changed, 3670 insertions(+) create mode 100644 client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx create mode 100644 client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx create mode 100644 client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx create mode 100644 client/app/types/system/marketplaceListings.ts diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 13243af9a80..50286e1469d 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -13,6 +13,10 @@ import { AllowlistRuleData, AllowlistRuleFormData, } from 'types/system/marketplaceAllowlist'; +import { + MarketplaceListingAdminData, + MarketplaceListingDetailData, +} from 'types/system/marketplaceListings'; import { AdminStats, UserListData } from 'types/users'; import BaseSystemAPI from '../Base'; @@ -193,6 +197,67 @@ export default class AdminAPI extends BaseSystemAPI { ); } + /** + * Fetches every marketplace listing with its version chain and provenance. + */ + indexMarketplaceListings(): Promise< + AxiosResponse<{ listings: MarketplaceListingAdminData[] }> + > { + return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_listings`); + } + + /** + * Fetches one listing's provenance, full version history and adoptions. Read-only — every + * mutation stays on the index. + */ + fetchMarketplaceListing( + id: number, + ): Promise> { + return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_listings/${id}`); + } + + /** + * PERMANENTLY deletes a marketplace listing, its versions and their container snapshots — not the + * reversible unlist. Succeeds with an EMPTY body (`head :ok`), so there is nothing to parse; the + * server refuses anything but an unadopted orphan with a 422 carrying `errors`. + */ + deleteMarketplaceListing(id: number): Promise> { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_listings/${id}`, + ); + } + + /** + * Takes a listing off the marketplace, or puts it back — the REVERSIBLE step, and the one an admin + * has to take before a listing can be deleted at all. Succeeds with an EMPTY body (`head :ok`). + * + * Admin-side rather than through the course-side unlist because that one resolves the listing + * through its authoring assessment, which a listing whose source was deleted no longer has. + * Re-listing never cuts a version: it restores visibility over the version already held. + */ + setMarketplaceListingPublished( + id: number, + published: boolean, + ): Promise> { + return this.client.patch( + `${AdminAPI.#urlPrefix}/marketplace_listings/${id}`, + { published }, + ); + } + + /** + * Duplicates an orphaned listing's latest snapshot into the marketplace's container course and + * makes it the new source assessment. There is no destination to choose. Asynchronous — the + * response carries a `jobUrl` for the client to poll. + */ + restoreMarketplaceListingAuthoring( + id: number, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_listings/${id}/restore_authoring`, + ); + } + /** * Creates a marketplace allow-list rule. */ diff --git a/client/app/bundles/system/admin/admin/AdminNavigator.tsx b/client/app/bundles/system/admin/admin/AdminNavigator.tsx index ddb5875f3c7..7b74de715dc 100644 --- a/client/app/bundles/system/admin/admin/AdminNavigator.tsx +++ b/client/app/bundles/system/admin/admin/AdminNavigator.tsx @@ -37,6 +37,10 @@ const translations = defineMessages({ id: 'system.admin.admin.AdminNavigator.marketplace', defaultMessage: 'Marketplace Access', }, + marketplaceListings: { + id: 'system.admin.admin.AdminNavigator.marketplaceListings', + defaultMessage: 'Marketplace Listings', + }, systemAdminPanel: { id: 'system.admin.admin.AdminNavigator.systemAdminPanel', defaultMessage: 'System Admin Panel', @@ -74,6 +78,11 @@ const AdminNavigator = (): JSX.Element => { title: t(translations.marketplace), path: '/admin/marketplace_allowlist_rules', }, + { + icon: , + title: t(translations.marketplaceListings), + path: '/admin/marketplace_listings', + }, { icon: , title: t(translations.getHelp), diff --git a/client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx new file mode 100644 index 00000000000..722b065725c --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button } from '@mui/material'; +import { AxiosError } from 'axios'; +import { MarketplaceListingAdminData } from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + listing: MarketplaceListingAdminData; + /** Called once the flip lands: `state` changes, and with it whether the row can be deleted. */ + onChanged: () => void; +} + +const translations = defineMessages({ + unlist: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlist', + defaultMessage: 'Unlist', + }, + list: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.list', + defaultMessage: 'List', + }, + unlistTitle: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlistTitle', + defaultMessage: 'Take this listing off the marketplace?', + }, + listTitle: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.listTitle', + defaultMessage: 'Put this listing back on the marketplace?', + }, + unlistExplanation: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlistExplanation', + defaultMessage: + 'It stops appearing in the marketplace and can no longer be copied or previewed. Nothing is deleted, and courses that already copied it are unaffected.', + }, + unlistReversible: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlistReversible', + defaultMessage: + 'This is reversible — you can list it again at any time. It is also what has to happen before a listing can be deleted permanently.', + }, + listExplanation: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.listExplanation', + defaultMessage: + 'It appears in the marketplace again, serving the version it already holds. No new version is published.', + }, + unlisted: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlisted', + defaultMessage: 'Listing taken off the marketplace.', + }, + listed: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.listed', + defaultMessage: 'Listing is back on the marketplace.', + }, + failed: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.failed', + defaultMessage: 'Could not change the listing’s visibility.', + }, +}); + +/** + * The reversible half of the maintenance pair — unlist, then delete — so the two always sit together + * on the row and the order between them is legible. + * + * One button rather than two, flipping with the state it reads: a listing is on the marketplace or it + * is not, and offering both actions at once would leave one of them permanently inert. + */ +const MarketplaceListingVisibilityButton = ({ + listing, + onChanged, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const listed = listing.state === 'published'; + + const submit = async (): Promise => { + setSubmitting(true); + try { + await SystemAPI.admin.setMarketplaceListingPublished(listing.id, !listed); + toast.success(t(listed ? translations.unlisted : translations.listed)); + setOpen(false); + onChanged(); + } catch (error) { + // The server owns the one rejection there is — listing something with no published version to + // serve — so its message is surfaced verbatim rather than restated here. + const message = + error instanceof AxiosError + ? error.response?.data?.errors?.[0] + : undefined; + toast.error(message ?? t(translations.failed)); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + + setOpen(false)} + open={open} + primaryColor={listed ? 'error' : 'primary'} + primaryLabel={t(listed ? translations.unlist : translations.list)} + title={t(listed ? translations.unlistTitle : translations.listTitle)} + > + + {t( + listed + ? translations.unlistExplanation + : translations.listExplanation, + )} + + + {listed && {t(translations.unlistReversible)}} + + + ); +}; + +export default MarketplaceListingVisibilityButton; diff --git a/client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx new file mode 100644 index 00000000000..e732de12432 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button } from '@mui/material'; +import { AxiosError } from 'axios'; +import { MarketplaceListingAdminData } from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; +import pollJob from 'lib/helpers/jobHelpers'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +const JOB_POLL_INTERVAL_MS = 2000; + +interface Props { + listing: MarketplaceListingAdminData; + /** + * Called once the rebuild job completes: `state`, `authoringAssessmentUrl` and `marketplaceHosted` + * all change. + */ + onRestored: () => void; +} + +const translations = defineMessages({ + restore: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.restore', + defaultMessage: 'Rebuild source assessment', + }, + confirm: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.confirm', + defaultMessage: 'Rebuild', + }, + explanation: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.explanation', + defaultMessage: + "The latest published version is copied into the marketplace's own container course as a new, editable source assessment, so that new versions can be published from it again.", + }, + intoContainer: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.intoContainer', + defaultMessage: + 'No live course is touched, and the published versions themselves are left unchanged.', + }, + completed: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.completed', + defaultMessage: 'Source assessment rebuilt in the marketplace container.', + }, + viewRestored: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.viewRestored', + defaultMessage: 'View assessment', + }, + failed: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.failed', + defaultMessage: 'Could not rebuild the source assessment.', + }, +}); + +const MarketplaceRestoreAuthoringButton = ({ + listing, + onRestored, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const close = (): void => { + setOpen(false); + }; + + const submit = async (): Promise => { + setSubmitting(true); + try { + const response = await SystemAPI.admin.restoreMarketplaceListingAuthoring( + listing.id, + ); + pollJob( + response.data.jobUrl, + // pollJob's *completion* callback — the duplication has finished by now, so the list can be + // refetched and `redirectUrl` points at the assessment that was just created. + (data) => { + toast.success( + <> + {t(translations.completed)} + {data.redirectUrl && ( + <> + {' '} + + {t(translations.viewRestored)} + + + )} + , + ); + setSubmitting(false); + close(); + onRestored(); + }, + () => { + toast.error(t(translations.failed)); + setSubmitting(false); + }, + JOB_POLL_INTERVAL_MS, + ); + } catch (error) { + // The server owns the rejection list (not orphaned, no version to restore from) and is the + // authority on it, so surface its message verbatim rather than restating those rules here. + const message = + error instanceof AxiosError + ? error.response?.data?.errors?.[0] + : undefined; + toast.error(message ?? t(translations.failed)); + setSubmitting(false); + } + }; + + return ( + <> + + + + {t(translations.explanation)} + + {t(translations.intoContainer)} + + + ); +}; + +export default MarketplaceRestoreAuthoringButton; diff --git a/client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx b/client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx new file mode 100644 index 00000000000..4fab9d18ff6 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx @@ -0,0 +1,568 @@ +import { defineMessages } from 'react-intl'; +import { StorefrontOutlined } from '@mui/icons-material'; +import { Chip, Tooltip, Typography } from '@mui/material'; +import { + MarketplaceListingAdminData, + MarketplaceListingState, +} from 'types/system/marketplaceListings'; + +import DeleteButton from 'lib/components/core/buttons/DeleteButton'; +import { PromptText } from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDate, formatLongDateTime } from 'lib/moment'; + +import MarketplaceListingVisibilityButton from '../buttons/MarketplaceListingVisibilityButton'; +import MarketplaceRestoreAuthoringButton from '../buttons/MarketplaceRestoreAuthoringButton'; + +interface Props { + listings: MarketplaceListingAdminData[]; + /** Permanent purge, not unlisting. Resolves once the list has been refetched. */ + onDelete: (id: number) => Promise; + onRestored: () => void; + /** The reversible unlist/list flip, which changes `state` and with it what else the row offers. */ + onVisibilityChanged: () => void; +} + +/** + * Filter-menu bucket for a listing with no recorded source instance. Only listings that were already + * orphaned when the column was introduced land here — the backfill had no source course to read the + * instance off, and nothing else on the row identifies the origin. It is a real bucket rather than an + * omission so an admin hunting a problem still sees those rows. + */ +const NO_INSTANCE = ''; + +/** + * A filter facet, deliberately NOT a fifth `MarketplaceListingState`. Marketplace-hosted answers WHERE + * the authoring copy lives, while the state values answer whether the listing is on the marketplace — + * and the two cross, so a row contributes both to the State filter menu and matches either + * independently. The value cannot collide with a real state. + */ +const MARKETPLACE_HOSTED = 'marketplace_hosted'; + +/** + * The complement of `MARKETPLACE_HOSTED`, and a filter value ONLY — never a chip, because it is the + * ordinary state of the world (see the State cell). With only one of the pair selectable, "show me + * the listings the marketplace does NOT maintain" was unaskable, which is the exact question an admin + * auditing who-owns-what arrives with. + * + * Labelled as a NEGATION rather than given a name of its own ("course-hosted"): these values share a + * menu with Published and Unlisted, and a second invented noun sitting beside two visibility states + * reads as a third state. A negation cannot be misread that way, and it keeps the surface at one term. + */ +const NOT_MARKETPLACE_HOSTED = 'not_marketplace_hosted'; + +type StateFilterValue = + | MarketplaceListingState + | typeof MARKETPLACE_HOSTED + | typeof NOT_MARKETPLACE_HOSTED; + +const translations = defineMessages({ + colId: { + id: 'system.admin.admin.MarketplaceListingsTable.colId', + defaultMessage: 'ID', + }, + // "Original", not "Source": the row carries both the assessment this listing was first published + // from and the one it is published from now, and after a rebuild those differ. The Actions link + // owns the live one as "source assessment"; this column owns the historical one, which is what + // makes a struck-through cell literally true rather than a claim that the listing is broken. + colTitle: { + id: 'system.admin.admin.MarketplaceListingsTable.colTitle', + defaultMessage: 'Original assessment', + }, + colSource: { + id: 'system.admin.admin.MarketplaceListingsTable.colSource', + defaultMessage: 'Source course', + }, + colInstance: { + id: 'system.admin.admin.MarketplaceListingsTable.colInstance', + defaultMessage: 'Instance', + }, + colVersion: { + id: 'system.admin.admin.MarketplaceListingsTable.colVersion', + defaultMessage: 'Version', + }, + colAdoptions: { + id: 'system.admin.admin.MarketplaceListingsTable.colAdoptions', + defaultMessage: 'Adoptions', + }, + colState: { + id: 'system.admin.admin.MarketplaceListingsTable.colState', + defaultMessage: 'State', + }, + colActions: { + id: 'system.admin.admin.MarketplaceListingsTable.colActions', + defaultMessage: 'Actions', + }, + statePublished: { + id: 'system.admin.admin.MarketplaceListingsTable.statePublished', + defaultMessage: 'Published', + }, + stateUnlisted: { + id: 'system.admin.admin.MarketplaceListingsTable.stateUnlisted', + defaultMessage: 'Unlisted', + }, + deletedSuffix: { + id: 'system.admin.admin.MarketplaceListingsTable.deletedSuffix', + defaultMessage: '(deleted)', + }, + assessmentDeletedHint: { + id: 'system.admin.admin.MarketplaceListingsTable.assessmentDeletedHint', + defaultMessage: + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.', + }, + courseDeletedHint: { + id: 'system.admin.admin.MarketplaceListingsTable.courseDeletedHint', + defaultMessage: + 'The course this listing was published from has been deleted. Its name is kept as a record of where the content came from.', + }, + marketplaceHosted: { + id: 'system.admin.admin.MarketplaceListingsTable.marketplaceHosted', + defaultMessage: 'Marketplace-hosted', + }, + notMarketplaceHosted: { + id: 'system.admin.admin.MarketplaceListingsTable.notMarketplaceHosted', + defaultMessage: 'Not marketplace-hosted', + }, + marketplaceHostedHint: { + id: 'system.admin.admin.MarketplaceListingsTable.marketplaceHostedHint', + defaultMessage: + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from.", + }, + // The label stays the same on every row, including the marketplace-hosted ones. Where the source + // assessment lives is already said twice on the row — by the Marketplace-hosted chip and by the + // struck-through Original assessment cell — and a label that rewords itself per row reads as a + // different action rather than the same one. + openSourceAssessment: { + id: 'system.admin.admin.MarketplaceListingsTable.openSourceAssessment', + defaultMessage: 'Open source assessment', + }, + filterNoInstance: { + id: 'system.admin.admin.MarketplaceListingsTable.filterNoInstance', + defaultMessage: 'Instance not recorded', + }, + searchText: { + id: 'system.admin.admin.MarketplaceListingsTable.searchText', + defaultMessage: 'Search listings by assessment title or source course', + }, + emptyTitle: { + id: 'system.admin.admin.MarketplaceListingsTable.emptyTitle', + defaultMessage: 'No assessments have been published yet.', + }, + unknown: { + id: 'system.admin.admin.MarketplaceListingsTable.unknown', + defaultMessage: '—', + }, + deleteTitle: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteTitle', + defaultMessage: 'Delete this listing permanently?', + }, + deleteConfirm: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteConfirm', + defaultMessage: + 'The listing, all of its versions and the snapshots those versions hold in the preview container will be deleted permanently. This cannot be undone. To merely take the listing off the marketplace, unlist it instead.', + }, + deleteConfirmUnlisted: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteConfirmUnlisted', + defaultMessage: + 'The listing, all of its versions and the snapshots those versions hold in the preview container will be deleted permanently. The source assessment is not affected and can be published again. This cannot be undone.', + }, + deleteTooltip: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteTooltip', + defaultMessage: 'Delete permanently', + }, + deleteBlockedTooltip: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteBlockedTooltip', + defaultMessage: + 'A published listing cannot be deleted. Unlist it first, so the reversible step comes before the irreversible one.', + }, + deleteAdoptionWarning: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteAdoptionWarning', + defaultMessage: + '{count, plural, one {# course has} other {# courses have}} adopted this listing. Their existing copies will not be affected, but the adoption history will be destroyed.', + }, +}); + +const MarketplaceListingsTable = ({ + listings, + onDelete, + onRestored, + onVisibilityChanged, +}: Props): JSX.Element => { + const { t } = useTranslation(); + + // The State column answers ONE question — is this listing on the marketplace — so the chip label + // and the filter label are the same words. Whether the origin still exists is a separate question, + // answered in the two Source columns, because a listing can be published and have a deleted origin + // at the same time. + const stateDisplay: Record = { + published: t(translations.statePublished), + unlisted: t(translations.stateUnlisted), + }; + + const stateColors = { + published: 'success', + unlisted: 'default', + } as const; + + // Mirrors `Listing#orphaned?`, which is about the AUTHORING copy and not about the origin: a + // rebuilt listing has a fresh copy in the container and is no longer orphaned, even though its + // original source assessment is gone for good. The url is null exactly when the copy is missing. + const isOrphaned = (listing: MarketplaceListingAdminData): boolean => + listing.authoringAssessmentUrl === null; + + // Mirrors `Listing#purgeable?`: a listing that is off the marketplace, orphaned or unlisted. A + // published listing has to be unlisted first, which keeps the reversible step ahead of the + // irreversible one. Adoption count plays no part — a deliberate deletion of an adopted listing is + // allowed; the confirm dialog is where that fact is surfaced, not a disabled button. + const isPurgeable = (listing: MarketplaceListingAdminData): boolean => + isOrphaned(listing) || listing.state === 'unlisted'; + + // A deleted origin is struck through AND suffixed: the strikethrough carries at a glance, the + // suffix carries for anyone who cannot see it, and the tooltip carries the part neither can — that + // the listing itself is fine. "Deleted" beside a live, serving listing reads as "broken" without it. + const deletedOrigin = (name: string, hint: string): JSX.Element => ( + + + {name}{' '} + {t(translations.deletedSuffix)} + + + ); + + const columns: ColumnTemplate[] = [ + { + of: 'id', + title: t(translations.colId), + sortable: true, + // The second entrance to the version history, and the only unconditional one. The Version cell + // beside it links only when a version exists, so a listing that has never published one had no + // route to its own page at all. Surfacing the id also gives the container's "Listing ID n" + // chips something to resolve against. + cell: (listing) => ( + + {listing.id} + + ), + }, + { + of: 'title', + title: t(translations.colTitle), + sortable: true, + searchable: true, + // This column and the one beside it describe the ORIGIN, so the link goes to the origin + // assessment and nowhere else — the same ABSOLUTE cross-instance url the action uses, since a + // course id resolves only on its own instance's host. + // + // Once the original is deleted the cell must NOT fall through to the authoring copy: after a + // rebuild that copy is a different assessment in the marketplace container, and pointing a + // column headed "Original assessment" at it would quietly claim the origin still exists. The + // copy keeps its own entrance in Actions. + cell: (listing): JSX.Element | string => { + const title = listing.title ?? t(translations.unknown); + + if (listing.sourceAssessmentDeleted) + return deletedOrigin(title, t(translations.assessmentDeletedHint)); + + return listing.authoringAssessmentUrl ? ( + + {title} + + ) : ( + title + ); + }, + }, + { + id: 'source', + title: t(translations.colSource), + sortable: true, + searchable: true, + // An explicit accessor is required because this column has no `of`: without one TanStack builds + // a display column that can neither sort nor take part in search. It is the course NAME, so + // searching "CS1010" narrows to that course's listings and sorting groups rows by origin. + // + // Deliberately NOT filterable: source courses number in the hundreds, most contributing one or + // two listings, and the filter is client-side over the loaded rows — a course menu would only + // grow as the feature succeeds. "Listings from CS1010" is a text query, not a set selection. + accessorFn: (listing) => listing.sourceCourseName ?? '', + // `//host/...`, not a relative path: `Course` is tenanted by instance, so a course id resolves + // only on its own instance's host and a relative link 404s for every listing published from + // another instance (same idiom as CoursesTable). + cell: (listing): JSX.Element | string => { + const name = listing.sourceCourseName ?? t(translations.unknown); + + if (listing.sourceCourseDeleted) + return deletedOrigin(name, t(translations.courseDeletedHint)); + + return listing.sourceCourseId && listing.sourceInstanceHost ? ( + + {listing.sourceCourseName ?? `#${listing.sourceCourseId}`} + + ) : ( + name + ); + }, + }, + { + id: 'instance', + // Just "Instance": adjacency to "Source course" already says whose instance it is, and the + // column is narrow. It answers "*which* CS1010?" when two instances each have one. + title: t(translations.colInstance), + sortable: true, + filterable: true, + accessorFn: (listing) => listing.sourceInstanceName ?? '', + // A filter rather than another searchable column: instances are a handful of stable values and + // a natural slice for an admin auditing one deployment's contributions. Putting it on its own + // header is what keeps the control honest — a filter menu of instance names hanging off the + // "Source course" header would claim to filter one dimension while filtering another. + filterProps: { + getValue: (listing) => [listing.sourceInstanceName ?? NO_INSTANCE], + getLabel: (value: string) => + value === NO_INSTANCE ? t(translations.filterNoInstance) : value, + shouldInclude: (listing, filterValue?: string[]) => + !filterValue?.length || + filterValue.includes(listing.sourceInstanceName ?? NO_INSTANCE), + }, + // Links to the instance's own landing page — `//host/`, protocol-relative for the same reason + // the source-course link is: the instance is only reachable on its own host. Plain text when + // the instance was never recorded, which is exactly the pre-column orphan case where there is + // no host either. + cell: (listing) => + listing.sourceInstanceName && listing.sourceInstanceHost ? ( + + {listing.sourceInstanceName} + + ) : ( + listing.sourceInstanceName ?? t(translations.unknown) + ), + }, + { + id: 'version', + title: t(translations.colVersion), + // The entrance to the version history, which is the ONLY index into the container course: + // publishing copies the assessment title verbatim into one shared tab, so version identity + // exists nowhere but the join table that page reads. An in-app route, so react-router `to` + // rather than `href`. + // + // Date, not date+time: this table shows ONE version per listing, so there is no sibling to + // disambiguate against and the time would be noise. It stays reachable on hover. + // + // `describeChild` because the full timestamp DESCRIBES this link, it does not name it. Without + // it MUI puts the tooltip title on the child as `aria-label`, which replaces the link's + // accessible name with the timestamp — leaving the link unreachable by its own visible text. + cell: (listing) => + listing.currentVersionPublishedAt ? ( + + + {formatLongDate(listing.currentVersionPublishedAt)} + + + ) : ( + t(translations.unknown) + ), + }, + { + of: 'adoptions', + title: t(translations.colAdoptions), + sortable: true, + // The shared builder hardcodes the 'alphanumeric' sorting function for every column, so an + // explicit comparator is what guarantees 12 sorts above 9 rather than below it. + sortProps: { sort: (a, b): number => a.adoptions - b.adoptions }, + // A count that raises "which courses?", and the listing page is where that list lives — so the + // number itself carries the answer. Zero stays plain text: there is nothing to go and look at, + // and the id beside it is already the unconditional entrance to the same page. + cell: (listing) => + listing.adoptions > 0 ? ( + + {listing.adoptions} + + ) : ( + listing.adoptions.toString() + ), + }, + { + of: 'state', + title: t(translations.colState), + filterable: true, + // The menu carries both states AND the marketplace-hosted facet, because "show me the listings + // the marketplace itself now maintains" is a question about this column that no state value can + // express. A hosted row contributes two values, so it appears under its own state and under the + // facet, and selecting the facet cuts across published and unlisted alike. + filterProps: { + // Every row contributes exactly one hosting value alongside its state, so the pair is always + // both offered and complete — selecting neither is the same as selecting both. + getValue: (listing) => [ + listing.state, + listing.marketplaceHosted + ? MARKETPLACE_HOSTED + : NOT_MARKETPLACE_HOSTED, + ], + getLabel: (value: StateFilterValue): string => { + if (value === MARKETPLACE_HOSTED) + return t(translations.marketplaceHosted); + if (value === NOT_MARKETPLACE_HOSTED) + return t(translations.notMarketplaceHosted); + + return stateDisplay[value]; + }, + shouldInclude: (listing, filterValue?: StateFilterValue[]) => + !filterValue?.length || + filterValue.includes(listing.state) || + filterValue.includes( + listing.marketplaceHosted + ? MARKETPLACE_HOSTED + : NOT_MARKETPLACE_HOSTED, + ), + }, + cell: (listing) => ( +
+ + + {/* Stacked beneath the state chip rather than replacing it: the row has to report both + visibility and where its source assessment lives. Outlined rather than coloured, because + this is provenance and not a health signal. + + Only the EXCEPTION is chipped. The opposite case — the source assessment still sitting in + the course that published it — is the ordinary state of the world, and the Source course + column two cells away already names that course and links to it, so a chip for it would + restate a neighbour on nearly every row. It would also cost the marker its job: when + every row is chipped, the one that needs an admin's attention stops standing out. The + filter offers the complement as a NEGATION for the same reason — see the menu below. */} + {listing.marketplaceHosted && ( + + + + )} +
+ ), + }, + { + id: 'actions', + title: t(translations.colActions), + // One horizontal row of actions in a fixed order — open, list/unlist, restore, delete — so an + // action always occupies the same slot regardless of how many the row offers. Every label is + // `whitespace-nowrap`: a narrow Actions column must never break a label across lines. + // + // A listing with no authoring copy renders no open action at all rather than a disabled one: + // there is nothing to open, and the rebuild action beside it says what to do instead. + // List/unlist sits second because delete depends on it — unlisting is the reversible step that + // has to precede a permanent deletion. Restore is orphaned-only and needs a version to copy. + // + // Delete is on every row, disabled rather than absent where the listing is still published, so + // its tooltip can state the rule: unlist first. Adoption count gates nothing — a deliberate + // deletion of an adopted listing must be allowed, and its confirm dialog surfaces that. + cell: (listing) => ( +
+ {listing.authoringAssessmentUrl && ( + // `href`, not react-router `to`: the server hands back an ABSOLUTE url carrying the + // source course's own instance host, which only a full navigation can follow. + + {t(translations.openSourceAssessment)} + + )} + + + + {isOrphaned(listing) && + listing.currentVersionPublishedAt !== null && ( + + )} + + + + {isOrphaned(listing) + ? t(translations.deleteConfirm) + : t(translations.deleteConfirmUnlisted)} + + + {listing.adoptions > 0 && ( + + {t(translations.deleteAdoptionWarning, { + count: listing.adoptions, + })} + + )} + + } + disabled={!isPurgeable(listing)} + onClick={(): Promise => onDelete(listing.id)} + title={t(translations.deleteTitle)} + // The disabled tooltip carries the reason, not just the name of the action: a disabled + // control that says only "Delete permanently" leaves the admin guessing. DeleteButton + // wraps the button in a `span`, so the tooltip still fires while it is disabled. + tooltip={ + isPurgeable(listing) + ? t(translations.deleteTooltip) + : t(translations.deleteBlockedTooltip) + } + /> +
+ ), + }, + ]; + + const emptyState = ( +
+ + + + {t(translations.emptyTitle)} + +
+ ); + + return ( + listing.id.toString()} + renderEmpty={emptyState} + search={{ searchPlaceholder: t(translations.searchText) }} + toolbar={{ show: true }} + /> + ); +}; + +export default MarketplaceListingsTable; diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx new file mode 100644 index 00000000000..49338578323 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx @@ -0,0 +1,461 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { + Chip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import { + MarketplaceListingDetailData, + MarketplaceListingState, +} from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Page from 'lib/components/core/layouts/Page'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +const translations = defineMessages({ + header: { + id: 'system.admin.admin.MarketplaceListingShow.header', + defaultMessage: 'Marketplace Listing', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceListingShow.fetchFailure', + defaultMessage: 'Failed to load this marketplace listing.', + }, + sourceCourse: { + id: 'system.admin.admin.MarketplaceListingShow.sourceCourse', + defaultMessage: 'Source course', + }, + instance: { + id: 'system.admin.admin.MarketplaceListingShow.instance', + defaultMessage: 'Instance', + }, + versionHistory: { + id: 'system.admin.admin.MarketplaceListingShow.versionHistory', + defaultMessage: 'Version history', + }, + colVersion: { + id: 'system.admin.admin.MarketplaceListingShow.colVersion', + defaultMessage: 'Version', + }, + colPublisher: { + id: 'system.admin.admin.MarketplaceListingShow.colPublisher', + defaultMessage: 'Published by', + }, + colContent: { + id: 'system.admin.admin.MarketplaceListingShow.colContent', + defaultMessage: 'Content', + }, + viewVersion: { + id: 'system.admin.admin.MarketplaceListingShow.viewVersion', + defaultMessage: 'View {version} content', + }, + // "Latest", matching the container's chip and the `apply_latest_version` route. The message id is + // left alone: renaming it would orphan the key in every locale file for a copy change. + currentBadge: { + id: 'system.admin.admin.MarketplaceListingShow.currentBadge', + defaultMessage: 'Latest', + }, + noVersions: { + id: 'system.admin.admin.MarketplaceListingShow.noVersions', + defaultMessage: 'No versions have been published yet.', + }, + adoptions: { + id: 'system.admin.admin.MarketplaceListingShow.adoptions', + defaultMessage: 'Adoptions', + }, + colCourse: { + id: 'system.admin.admin.MarketplaceListingShow.colCourse', + defaultMessage: 'Course', + }, + colVersionHeld: { + id: 'system.admin.admin.MarketplaceListingShow.colVersionHeld', + defaultMessage: 'Version held', + }, + colAdoptedAt: { + id: 'system.admin.admin.MarketplaceListingShow.colAdoptedAt', + defaultMessage: 'Adopted', + }, + noAdoptions: { + id: 'system.admin.admin.MarketplaceListingShow.noAdoptions', + defaultMessage: 'No courses have adopted this listing yet.', + }, + statePublished: { + id: 'system.admin.admin.MarketplaceListingShow.statePublished', + defaultMessage: 'Published', + }, + stateUnlisted: { + id: 'system.admin.admin.MarketplaceListingShow.stateUnlisted', + defaultMessage: 'Unlisted', + }, + deletedSuffix: { + id: 'system.admin.admin.MarketplaceListingShow.deletedSuffix', + defaultMessage: '(deleted)', + }, + // Rendered ONLY when the original is gone. While it exists there is nothing to say: the heading + // names it and "Open source assessment" opens it, so a field repeating the heading's own text + // would be noise — and the label is what lets this state the deletion without repeating it either. + originalAssessment: { + id: 'system.admin.admin.MarketplaceListingShow.originalAssessment', + defaultMessage: 'Original assessment', + }, + originalDeleted: { + id: 'system.admin.admin.MarketplaceListingShow.originalDeleted', + defaultMessage: 'deleted', + }, + assessmentDeletedHint: { + id: 'system.admin.admin.MarketplaceListingShow.assessmentDeletedHint', + defaultMessage: + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.', + }, + courseDeletedHint: { + id: 'system.admin.admin.MarketplaceListingShow.courseDeletedHint', + defaultMessage: + 'The course this listing was published from has been deleted. Its name is kept as a record of where the content came from.', + }, + openSourceAssessment: { + id: 'system.admin.admin.MarketplaceListingShow.openSourceAssessment', + defaultMessage: 'Open source assessment', + }, + marketplaceHosted: { + id: 'system.admin.admin.MarketplaceListingShow.marketplaceHosted', + defaultMessage: 'Marketplace-hosted', + }, + marketplaceHostedHint: { + id: 'system.admin.admin.MarketplaceListingShow.marketplaceHostedHint', + defaultMessage: + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from.", + }, + unknown: { + id: 'system.admin.admin.MarketplaceListingShow.unknown', + defaultMessage: '—', + }, +}); + +const STATE_COLORS = { + published: 'success', + unlisted: 'default', +} as const; + +interface LoadedListing { + listingId: string; + data: MarketplaceListingDetailData; +} + +const MarketplaceListingShow: FC = () => { + const { t } = useTranslation(); + const { listingId } = useParams(); + const [loadedListing, setLoadedListing] = useState(); + const [failed, setFailed] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!listingId) { + setLoadedListing(undefined); + setFailed(false); + setLoading(false); + return (): void => {}; + } + + let active = true; + setLoadedListing(undefined); + setFailed(false); + setLoading(true); + + SystemAPI.admin + .fetchMarketplaceListing(Number(listingId)) + .then((response) => { + if (active) setLoadedListing({ listingId, data: response.data }); + }) + .catch(() => { + if (active) setFailed(true); + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, [listingId]); + + // Marketplace visibility, and only that. Whether either origin still exists is reported on the + // provenance line below, because a listing can be published and have a deleted origin at once. + const stateLabel = (state: MarketplaceListingState): string => + state === 'published' + ? t(translations.statePublished) + : t(translations.stateUnlisted); + + // Matches the index table's treatment exactly, so the same fact does not arrive in two shapes: + // struck through for the glance, suffixed for anyone who cannot see that, and a tooltip for the + // part neither carries — that the listing itself still works. + const deletedOrigin = (name: string, hint: string): JSX.Element => ( + + + {name}{' '} + {t(translations.deletedSuffix)} + + + ); + + // Date AND time: this page puts versions side by side, and two cuts on the same day are exactly + // what an admin comes here to tell apart. + const date = (value: string | null): string => + value ? formatLongDateTime(value) : t(translations.unknown); + const listing = + loadedListing && loadedListing.listingId === listingId + ? loadedListing.data + : undefined; + + if (loading) return ; + + if (failed || !listing) { + return ( + + + {t(translations.fetchFailure)} + + + ); + } + + // Links to the instance's own landing page, matching the index table's Instance column. Plain text + // when the instance was never recorded — the pre-column orphan case, which has no host either. + const sourceInstanceName = (): JSX.Element | string => { + if (!listing.sourceInstanceName || !listing.sourceInstanceHost) + return listing.sourceInstanceName ?? t(translations.unknown); + + return ( + + {listing.sourceInstanceName} + + ); + }; + + // `//host/...`: Course is tenanted by instance, so a course id resolves only on its own host. + const sourceCourseName = (): JSX.Element | string => { + const name = listing.sourceCourseName ?? t(translations.unknown); + + if (listing.sourceCourseDeleted) + return deletedOrigin(name, t(translations.courseDeletedHint)); + + return listing.sourceCourseId && listing.sourceInstanceHost ? ( + + {listing.sourceCourseName ?? `#${listing.sourceCourseId}`} + + ) : ( + name + ); + }; + + return ( + +
+
+ {/* Plain text, and never struck through: this is the LISTING's identity, not the original + assessment's name, so striking it would say the listing is dead when it is serving + normally. The index table can strike its equivalent cell only because a column header + spells out that the cell means the ORIGINAL; a heading has no such frame, so the + deletion is stated on the provenance line below instead. Unlinked for the same reason — + "Open source assessment" beside it is the entrance, and it names what it opens. */} + + {listing.title ?? t(translations.unknown)} + + + + + {/* Beside the state chip, not instead of it: this page exists to answer provenance + questions, and the Source course line below still names the ORIGIN course after a + rebuild — so without this the page would be less truthful than the index table. */} + {listing.marketplaceHosted && ( + + + + )} +
+ +
+ + {t(translations.sourceCourse)}: {sourceCourseName()} + + + + {t(translations.instance)}: {sourceInstanceName()} + + + {/* The deletion of the ORIGINAL, stated here rather than on the heading: the heading is the + listing's identity, and marking it would say the listing is broken. Labelled, so it + needs no strikethrough and no repeat of the title to be understood. */} + {listing.sourceAssessmentDeleted && ( + + {t(translations.originalAssessment)}:{' '} + + + {t(translations.originalDeleted)} + + + + )} + + {/* The assessment new versions are published from — after a rebuild a DIFFERENT record + living in the marketplace's preview course, so it needs its own entrance rather than + sharing the heading's link, which points at the original. */} + {listing.authoringAssessmentUrl && ( + + + {t(translations.openSourceAssessment)} + + + )} +
+
+ + + {t(translations.versionHistory)} + + + {listing.versions.length === 0 ? ( + + {t(translations.noVersions)} + + ) : ( +
+ + + {t(translations.colVersion)} + {t(translations.colPublisher)} + {t(translations.colContent)} + + + + + {listing.versions.map((version) => ( + + +
+ {date(version.publishedAt)} + {version.isCurrent && ( + + )} +
+
+ + {version.publisherName ?? t(translations.unknown)} + + + {/* A new tab, not a navigation: the snapshot is a reference the admin returns + from, and leaving would discard the index table's search/sort/filter state. */} + {version.snapshotUrl ? ( + + {t(translations.viewVersion, { + version: date(version.publishedAt), + })} + + ) : ( + t(translations.unknown) + )} + +
+ ))} +
+
+ )} + + + {t(translations.adoptions)} + + + {listing.adoptions.length === 0 ? ( + + {t(translations.noAdoptions)} + + ) : ( + + + + {t(translations.colCourse)} + {t(translations.colVersionHeld)} + {t(translations.colAdoptedAt)} + {t(translations.colContent)} + + + + + {listing.adoptions.map((adoption) => ( + + + {adoption.destinationCourseId && + adoption.destinationCourseHost ? ( + + {adoption.destinationCourseName ?? + `#${adoption.destinationCourseId}`} + + ) : ( + adoption.destinationCourseName ?? t(translations.unknown) + )} + + {date(adoption.adoptedVersionAt)} + {date(adoption.adoptedAt)} + + {adoption.snapshotUrl && adoption.adoptedVersionAt ? ( + + {t(translations.viewVersion, { + version: date(adoption.adoptedVersionAt), + })} + + ) : ( + t(translations.unknown) + )} + + + ))} + +
+ )} +
+ ); +}; + +export default MarketplaceListingShow; diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx new file mode 100644 index 00000000000..fe95f5e6c3f --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx @@ -0,0 +1,94 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Typography } from '@mui/material'; +import { AxiosError } from 'axios'; +import { MarketplaceListingAdminData } from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import MarketplaceListingsTable from '../components/tables/MarketplaceListingsTable'; + +const translations = defineMessages({ + header: { + id: 'system.admin.admin.MarketplaceListingsIndex.header', + defaultMessage: 'Marketplace Listings', + }, + // The second sentence is here rather than in a tooltip because it is the one thing about this page + // that cannot be inferred from any row: that a deleted original does not end the listing. Without + // it, "Original assessment (deleted)" beside a Published chip looks like a contradiction. + subtitle: { + id: 'system.admin.admin.MarketplaceListingsIndex.subtitle', + defaultMessage: + 'Every published assessment, the version currently served, and how many courses have copied it. Publish a new version from the source assessment - if the original is deleted, a source assessment is saved in the marketplace’s preview course so new versions can still be published.', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceListingsIndex.fetchFailure', + defaultMessage: 'Failed to load marketplace listings.', + }, + deleteSuccess: { + id: 'system.admin.admin.MarketplaceListingsIndex.deleteSuccess', + defaultMessage: 'Listing deleted permanently.', + }, + deleteFailure: { + id: 'system.admin.admin.MarketplaceListingsIndex.deleteFailure', + defaultMessage: 'Failed to delete the listing.', + }, +}); + +const MarketplaceListingsIndex: FC = () => { + const { t } = useTranslation(); + const [listings, setListings] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchListings = (): Promise => + SystemAPI.admin + .indexMarketplaceListings() + .then((response) => setListings(response.data.listings)) + .catch(() => { + toast.error(t(translations.fetchFailure)); + }); + + useEffect(() => { + fetchListings().finally(() => setLoading(false)); + }, []); + + const handleDelete = async (id: number): Promise => { + try { + // A 200 here carries an EMPTY body (`head :ok`) — there is nothing to read off the response. + await SystemAPI.admin.deleteMarketplaceListing(id); + toast.success(t(translations.deleteSuccess)); + await fetchListings(); + } catch (error) { + // The server enforces the same orphaned-and-unadopted rule the buttons do and is the + // authority on it, so show its reason rather than the generic fallback when it disagrees. + const message = + error instanceof AxiosError + ? error.response?.data?.errors?.[0] + : undefined; + toast.error(message ?? t(translations.deleteFailure)); + } + }; + + if (loading) return ; + + return ( + + + {t(translations.subtitle)} + + + + + ); +}; + +export default MarketplaceListingsIndex; diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx new file mode 100644 index 00000000000..d27d1f0916d --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx @@ -0,0 +1,552 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { act, render, waitFor, within } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +import SystemAPI from 'api/system'; + +import MarketplaceListingShow from '../MarketplaceListingShow'; + +const SHOW_URL = '/admin/marketplace_listings/1'; +const SECOND_SHOW_URL = '/admin/marketplace_listings/2'; +const CONTAINER_V1 = 'http://preview.example.org/courses/7/assessments/101'; +const CONTAINER_V2 = 'http://preview.example.org/courses/7/assessments/102'; +const LISTING_TITLE = 'Recursion Drill'; +const SECOND_LISTING_TITLE = 'Sorting Drill'; +const MARKETPLACE_HOSTED = 'Marketplace-hosted'; +const MARKETPLACE_HOSTED_HINT = + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from."; +const AUTHORING_URL = 'http://main.example.org/courses/9/assessments/12'; +const CONTAINER_AUTHORING_URL = + 'http://preview.example.org/courses/7/assessments/200'; +const OPEN_ACTION = 'Open source assessment'; +const DELETED_SUFFIX = '(deleted)'; +const SOURCE_COURSE_NAME = 'Intro to Programming'; +const VERSION_HISTORY = 'Version history'; +const INSTANCE_NAME = 'Main Campus'; +const ASSESSMENT_DELETED_HINT = + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.'; +let mockListingId = '1'; + +// Under TZ=Asia/Singapore these render as '15 Jan 2026, 6:00pm' and '20 Jun 2026, 6:00pm'. +const V1_AT = '2026-01-15T10:00:00.000Z'; +const V1_LABEL = '15 Jan 2026, 6:00pm'; +const V2_AT = '2026-06-20T10:00:00.000Z'; +const V2_LABEL = '20 Jun 2026, 6:00pm'; + +// `TestApp` mounts the component directly inside a `MemoryRouter` with no matching +// ``, so `useParams()` would otherwise be empty and the page would never +// fetch. Mock it to supply the route param — the same idiom as +// `course/marketplace/pages/ListingPreview/__test__/index.test.tsx`. +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: (): { listingId: string } => ({ listingId: mockListingId }), +})); + +const mock = createMockAdapter(SystemAPI.admin.client); + +beforeEach(() => { + mock.reset(); + mockListingId = '1'; +}); + +const detail = (overrides = {}): unknown => ({ + id: 1, + title: LISTING_TITLE, + currentVersionPublishedAt: V2_AT, + state: 'published', + marketplaceHosted: false, + sourceAssessmentDeleted: false, + sourceCourseDeleted: false, + authoringAssessmentUrl: AUTHORING_URL, + sourceCourseId: 9, + sourceCourseName: SOURCE_COURSE_NAME, + sourceInstanceName: INSTANCE_NAME, + sourceInstanceHost: 'main.example.org', + versions: [ + { + publishedAt: V1_AT, + publisherName: 'Ada Admin', + isCurrent: false, + snapshotUrl: CONTAINER_V1, + }, + { + publishedAt: V2_AT, + publisherName: 'Bob Admin', + isCurrent: true, + snapshotUrl: CONTAINER_V2, + }, + ], + adoptions: [ + { + id: 5, + destinationCourseId: 77, + destinationCourseName: 'Adopting Course', + destinationCourseHost: 'other.example.org', + adoptedVersionAt: V1_AT, + adoptedAt: '2026-02-01T10:00:00.000Z', + snapshotUrl: CONTAINER_V1, + }, + ], + ...overrides, +}); + +const renderPage = (): ReturnType => + render(, { at: [SHOW_URL] }); + +it('names the listing and its provenance', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText(SOURCE_COURSE_NAME)).toBeInTheDocument(); + expect(page.getByText(new RegExp(INSTANCE_NAME))).toBeInTheDocument(); +}); + +// The heading is the LISTING's identity, so it is plain text in every state — never a link (whose +// typography would shrink it inside the h6) and never struck through (which would say the listing is +// dead while it serves normally). "Open source assessment" beside it is the entrance. +it('keeps the heading plain text and puts the entrance beside it', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: LISTING_TITLE }), + ).not.toBeInTheDocument(); + expect(page.getByRole('link', { name: OPEN_ACTION })).toHaveAttribute( + 'href', + AUTHORING_URL, + ); +}); + +it('links the source course on its own instance host', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect( + await page.findByRole('link', { name: SOURCE_COURSE_NAME }), + ).toHaveAttribute('href', '//main.example.org/courses/9/assessments'); +}); + +// Same rule as the index table's Instance column: the instance is only reachable on its own host. +it('links the instance to its own host', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect( + await page.findByRole('link', { name: INSTANCE_NAME }), + ).toHaveAttribute('href', '//main.example.org/'); +}); + +it('leaves the instance as plain text when none was recorded', async () => { + mock + .onGet(SHOW_URL) + .reply(200, detail({ sourceInstanceName: null, sourceInstanceHost: null })); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: INSTANCE_NAME }), + ).not.toBeInTheDocument(); +}); + +// Stated on the provenance line under its own label, NOT on the heading: a label carries the meaning +// without a strikethrough and without repeating the title, which the heading already shows. +it('reports a deleted original on the provenance line, leaving the heading intact', async () => { + mock.onGet(SHOW_URL).reply( + 200, + detail({ + sourceAssessmentDeleted: true, + marketplaceHosted: true, + authoringAssessmentUrl: CONTAINER_AUTHORING_URL, + }), + ); + + const page = renderPage(); + + const heading = await page.findByText(LISTING_TITLE); + expect(heading).not.toHaveClass('line-through'); + expect(page.getByText(/Original assessment/)).toBeInTheDocument(); + expect(page.getByLabelText(ASSESSMENT_DELETED_HINT)).toHaveTextContent( + 'deleted', + ); + // The entrance follows the source assessment to the preview course. + expect(page.getByRole('link', { name: OPEN_ACTION })).toHaveAttribute( + 'href', + CONTAINER_AUTHORING_URL, + ); +}); + +it('says nothing about the original while it still exists', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.queryByText(/Original assessment/)).not.toBeInTheDocument(); +}); + +// The course NAME survives its deletion and is worth keeping on screen, so unlike the assessment it +// is struck through in place rather than replaced by a bare "deleted". +it('strikes out and unlinks a deleted source course', async () => { + mock.onGet(SHOW_URL).reply( + 200, + detail({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + sourceCourseId: null, + authoringAssessmentUrl: null, + }), + ); + + const page = renderPage(); + + expect(await page.findByText(SOURCE_COURSE_NAME)).toHaveClass('line-through'); + expect( + page.queryByRole('link', { name: SOURCE_COURSE_NAME }), + ).not.toBeInTheDocument(); + // Nothing to open while the listing has no source assessment at all. + expect( + page.queryByRole('link', { name: OPEN_ACTION }), + ).not.toBeInTheDocument(); + expect(page.getByText(DELETED_SUFFIX, { exact: false })).toBeInTheDocument(); +}); + +// The Source course line keeps naming the ORIGIN course after a rebuild — that provenance is a +// historical fact the rebuild leaves alone — so the marker is what stops this page reading as though +// the source assessment were still sitting in that course. +it('marks a marketplace-hosted listing alongside its state, keeping the origin provenance', async () => { + mock.onGet(SHOW_URL).reply(200, detail({ marketplaceHosted: true })); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText('Published')).toBeInTheDocument(); + expect(page.getByLabelText(MARKETPLACE_HOSTED_HINT)).toHaveTextContent( + MARKETPLACE_HOSTED, + ); + expect(page.getByText(SOURCE_COURSE_NAME)).toBeInTheDocument(); +}); + +it('shows no marketplace-hosted marker for a listing with its own source course', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.queryByText(MARKETPLACE_HOSTED)).not.toBeInTheDocument(); +}); + +// Every version is listed, including the current one — the point of the page is the whole chain. +it('lists every version with its publisher and marks the current one', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const history = page.getByRole('table', { name: VERSION_HISTORY }); + const rows = within(history).getAllByRole('row').slice(1); + + expect(rows).toHaveLength(2); + expect(within(rows[0]).getByText(V1_LABEL)).toBeInTheDocument(); + expect(within(rows[0]).getByText('Ada Admin')).toBeInTheDocument(); + expect(within(rows[1]).getByText(V2_LABEL)).toBeInTheDocument(); + expect(within(rows[1]).getByText('Bob Admin')).toBeInTheDocument(); + + // Only the served version carries the badge. "Latest", not "Current": the adoptions table below + // has a "Version held" column, so *current* invites the question "current to whom?" — and the + // container's chips and the `apply_latest_version` route already say latest. + expect(within(rows[1]).getByText('Latest')).toBeInTheDocument(); + expect(within(rows[0]).queryByText('Latest')).not.toBeInTheDocument(); + + // The Version column now carries the publish datetime, so a separate Published column would + // repeat it verbatim. + expect(within(rows[0]).getAllByRole('cell')).toHaveLength(3); +}); + +// The snapshot lives in the container course on the preview host, and the admin returns to this page +// afterwards — so the link must not replace it. +it('links each version to its container snapshot in a new tab', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const history = page.getByRole('table', { name: VERSION_HISTORY }); + const link = within(history).getByRole('link', { + name: `View ${V1_LABEL} content`, + }); + expect(link).toHaveAttribute('href', CONTAINER_V1); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); +}); + +it('renders a version with no surviving snapshot as plain text, not a link', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + versions: [ + { + publishedAt: V1_AT, + publisherName: 'Ada Admin', + isCurrent: true, + snapshotUrl: null, + }, + ], + }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + within(page.getByRole('table', { name: VERSION_HISTORY })).queryByRole( + 'link', + { name: `View ${V1_LABEL} content` }, + ), + ).not.toBeInTheDocument(); +}); + +it('reports which version each adopting course holds', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const adoptions = page.getByRole('table', { name: 'Adoptions' }); + const rows = within(adoptions).getAllByRole('row').slice(1); + + expect(rows).toHaveLength(1); + expect(within(rows[0]).getByText('Adopting Course')).toBeInTheDocument(); + expect(within(rows[0]).getByText(V1_LABEL)).toBeInTheDocument(); +}); + +// Unusual but valid, so it reports rather than the section vanishing. +it('shows an inline empty state when nothing has adopted the listing', async () => { + mock.onGet(SHOW_URL).reply(200, { ...(detail() as object), adoptions: [] }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + page.getByText('No courses have adopted this listing yet.'), + ).toBeInTheDocument(); + expect( + page.queryByRole('table', { name: 'Adoptions' }), + ).not.toBeInTheDocument(); +}); + +// Losing the origin removes the authoring copy, not the history — and not the listing's place on the +// marketplace either, since the copy is rebuilt automatically. +it('still renders the history, and stays published, when the origin was deleted', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + sourceAssessmentDeleted: true, + marketplaceHosted: true, + authoringAssessmentUrl: CONTAINER_AUTHORING_URL, + }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText('Published')).toBeInTheDocument(); + expect( + within(page.getByRole('table', { name: VERSION_HISTORY })).getAllByRole( + 'row', + ), + ).toHaveLength(3); +}); + +it('reports an unlisted listing as unlisted', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + state: 'unlisted', + }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText('Unlisted')).toBeInTheDocument(); +}); + +it('renders an empty history without crashing when there is no version', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + currentVersionPublishedAt: null, + versions: [], + }); + + const page = renderPage(); + + expect( + await page.findByText('No versions have been published yet.'), + ).toBeInTheDocument(); +}); + +it('uses an em dash for unknown values', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + title: null, + sourceCourseName: null, + sourceInstanceName: null, + versions: [ + { + publishedAt: null, + publisherName: null, + isCurrent: true, + snapshotUrl: null, + }, + ], + adoptions: [ + { + id: 5, + destinationCourseId: null, + destinationCourseName: null, + destinationCourseHost: null, + adoptedVersionAt: null, + adoptedAt: null, + snapshotUrl: null, + }, + ], + }); + + const page = renderPage(); + + expect(await page.findByText('Marketplace Listing')).toBeInTheDocument(); + expect(page.getAllByText('—').length).toBeGreaterThan(1); +}); + +it('toasts and renders nothing when the fetch fails', async () => { + mock.onGet(SHOW_URL).reply(500); + + const page = renderPage(); + + expect( + await page.findByText('Failed to load this marketplace listing.'), + ).toBeInTheDocument(); +}); + +it('loads a new listing after the previous listing failed', async () => { + mock.onGet(SHOW_URL).reply(500); + mock.onGet(SECOND_SHOW_URL).reply( + 200, + detail({ + id: 2, + title: SECOND_LISTING_TITLE, + }), + ); + + const page = renderPage(); + await page.findByText('Failed to load this marketplace listing.'); + + mockListingId = '2'; + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + expect(await page.findByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByText('Failed to load this marketplace listing.'), + ).not.toBeInTheDocument(); +}); + +it('does not keep the previous listing visible while a new listing loads', async () => { + let resolveSecondRequest: (response: [number, unknown]) => void; + mock.onGet(SHOW_URL).reply(200, detail()); + mock.onGet(SECOND_SHOW_URL).reply( + () => + new Promise((resolve) => { + resolveSecondRequest = resolve; + }), + ); + + const page = renderPage(); + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + mockListingId = '2'; + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + expect(page.queryByText(LISTING_TITLE)).not.toBeInTheDocument(); + await waitFor(() => expect(resolveSecondRequest).toBeDefined()); + + await act(async () => { + resolveSecondRequest!([ + 200, + detail({ + id: 2, + title: SECOND_LISTING_TITLE, + }), + ]); + }); + + expect(await page.findByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); +}); + +it('keeps the new listing when a superseded request fails late', async () => { + let resolveFirstRequest: (response: [number]) => void; + mock.onGet(SHOW_URL).reply( + () => + new Promise((resolve) => { + resolveFirstRequest = resolve; + }), + ); + mock.onGet(SECOND_SHOW_URL).reply( + 200, + detail({ + id: 2, + title: SECOND_LISTING_TITLE, + }), + ); + + const page = renderPage(); + await waitFor(() => expect(resolveFirstRequest).toBeDefined()); + + mockListingId = '2'; + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + expect(await page.findByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); + + await act(async () => { + resolveFirstRequest!([500]); + }); + + expect(page.getByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByText('Failed to load this marketplace listing.'), + ).not.toBeInTheDocument(); +}); + +// The history is where two cuts sit side by side, so the time is what tells a same-day pair apart — +// and no ordinal survives anywhere on the page. +it('names versions by datetime and carries no ordinal', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const history = page.getByRole('table', { name: VERSION_HISTORY }); + expect(within(history).queryByText(/^v\d+$/)).not.toBeInTheDocument(); + + const adoptions = page.getByRole('table', { name: 'Adoptions' }); + expect(within(adoptions).queryByText(/^v\d+$/)).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx new file mode 100644 index 00000000000..78a380cf3e9 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx @@ -0,0 +1,1522 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import GlobalAPI from 'api'; +import SystemAPI from 'api/system'; +import toast from 'lib/hooks/toast'; + +import MarketplaceListingsIndex from '../MarketplaceListingsIndex'; + +// The restore completion toast is a ReactNode (it carries a link), so capture it and render it +// rather than mounting a ToastContainer. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + +const INDEX_URL = '/admin/marketplace_listings'; +const SEARCH_PLACEHOLDER = + 'Search listings by assessment title or source course'; +const RECURSION_DRILL = 'Recursion Drill'; +const ARRAYS_WARMUP = 'Arrays Warmup'; +const RETIRED_QUIZ = 'Retired Quiz'; +const RESTORE_ACTION = 'Rebuild source assessment'; +const OPEN_ACTION = 'Open source assessment'; +const MAIN_CAMPUS = 'Main Campus'; +const SATELLITE_CAMPUS = 'Satellite Campus'; +const ASSESSMENT_DELETED_HINT = + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.'; +const COURSE_DELETED_HINT = + 'The course this listing was published from has been deleted. Its name is kept as a record of where the content came from.'; +const DELETED_SUFFIX = '(deleted)'; +const SOURCE_COURSE_NAME = 'Intro to Programming'; +const MARKETPLACE_HOSTED = 'Marketplace-hosted'; +const MARKETPLACE_HOSTED_HINT = + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from."; +const ID_COLUMN = 0; +const TITLE_COLUMN = 1; +const SOURCE_COLUMN = 2; +const INSTANCE_COLUMN = 3; +const VERSION_COLUMN = 4; +const ADOPTIONS_COLUMN = 5; +const STATE_COLUMN = 6; +const AUTHORING_URL = 'http://main.coursemology.org/courses/9/assessments/12'; +const DELETE_BLOCKED_TOOLTIP = + 'A published listing cannot be deleted. Unlist it first, so the reversible step comes before the irreversible one.'; +// pollJob keeps its interval running after the component unmounts (see its own docstring), so a +// poller started by one test outlives that test. Every test therefore gets its OWN job url: a stray +// poller then finds no handler registered for it and cannot satisfy — or break — the next test's +// assertions by resolving against that test's job handler. +const UNWATCHED_JOB_URL = '/jobs/unwatched'; +const COMPLETED_JOB_URL = '/jobs/completed'; +const ERRORED_JOB_URL = '/jobs/errored'; +const RESTORED_URL = '/courses/77/assessments/321'; + +const mock = createMockAdapter(SystemAPI.admin.client); +// pollJob polls the *jobs* endpoint, which lives on a different axios client to the admin API. +const jobsMock = createMockAdapter(GlobalAPI.jobs.client); + +beforeEach(() => { + mock.reset(); + jobsMock.reset(); + jest.clearAllMocks(); +}); + +const listingAt = (overrides = {}): unknown => ({ + id: 1, + title: RECURSION_DRILL, + currentVersionPublishedAt: '2026-07-24T07:04:00.000Z', + lastPublishedAt: '2026-07-20T10:00:00.000Z', + adoptions: 4, + sourceCourseId: 9, + sourceCourseName: SOURCE_COURSE_NAME, + sourceInstanceName: MAIN_CAMPUS, + sourceInstanceHost: 'main.coursemology.org', + state: 'published', + marketplaceHosted: false, + sourceAssessmentDeleted: false, + sourceCourseDeleted: false, + authoringAssessmentUrl: AUTHORING_URL, + ...overrides, +}); + +/** + * How many times the listings index itself has been fetched. Counted by url rather than off + * `mock.history.get.length`, which also holds the adapter's `/csrf_token` handshakes. + */ +const indexFetchCount = (): number => + mock.history.get.filter((request) => request.url === INDEX_URL).length; + +/** Text of one column across every body row, in the order the rows are rendered. */ +const columnTexts = ( + page: ReturnType, + columnIndex: number, +): (string | null)[] => + page + .getAllByRole('row') + .slice(1) + .map((row) => within(row).getAllByRole('cell')[columnIndex].textContent); + +/** + * Open one column's filter menu, click one of its items, then close the menu again — an open MUI menu + * marks the rest of the page `aria-hidden`, so the table rows are unqueryable until it is. Scoped to + * the column's own header cell: the table now carries two filter menus, both tooltipped "Filter". + */ +const clickFilterItem = async ( + page: ReturnType, + columnIndex: number, + itemName: string, +): Promise => { + const header = page.getAllByRole('columnheader')[columnIndex]; + fireEvent.click(within(header).getByRole('button', { name: 'Filter' })); + fireEvent.click(await page.findByRole('menuitem', { name: itemName })); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(page.queryByRole('menu')).not.toBeInTheDocument()); +}; + +const clickStateFilterItem = ( + page: ReturnType, + itemName: string, +): Promise => clickFilterItem(page, STATE_COLUMN, itemName); + +const clickInstanceFilterItem = ( + page: ReturnType, + itemName: string, +): Promise => clickFilterItem(page, INSTANCE_COLUMN, itemName); + +it('renders a listing row with its version, adoptions and provenance', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(page.getByText('24 Jul 2026')).toBeInTheDocument(); + expect(page.getByText('4')).toBeInTheDocument(); + expect(page.getByText(SOURCE_COURSE_NAME)).toBeInTheDocument(); +}); + +// The served vintage is the only entrance to the version history, which is in turn the only index +// into the container course. One version per row means nothing to disambiguate against, so the time +// would be pure noise in an already-crowded table — it stays reachable on hover instead. +it('links the served vintage to the listing version history', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + // Queried by its visible text rather than by accessible name: the cell's Tooltip puts the full + // timestamp on the anchor, and dom-accessibility-api prefers that over the name-from-content, so a + // `name` query here would assert the tooltip's wording (and its timezone) instead of the link. + const link = (await page.findByText('24 Jul 2026')).closest('a'); + expect(link).toHaveAttribute('href', '/admin/marketplace_listings/1'); +}); + +it('carries no version ordinal anywhere in the row', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, VERSION_COLUMN)).toEqual(['24 Jul 2026']); +}); + +it('renders the empty marker instead of a link when there is no version', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ currentVersionPublishedAt: null })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, VERSION_COLUMN)).toEqual(['—']); + expect( + page.queryByRole('link', { name: '24 Jul 2026' }), + ).not.toBeInTheDocument(); +}); + +// The link only navigates; the publishing itself happens on the assessment page, so the label says +// what the action does rather than what the destination page offers. +it('links Open source assessment to the authoring assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + // The server hands back an ABSOLUTE url carrying the source course's own instance host, because a + // course id resolves nowhere else — so this must be followed as a plain href, not a client route. + const link = await page.findByRole('link', { name: OPEN_ACTION }); + expect(link).toHaveAttribute( + 'href', + 'http://main.coursemology.org/courses/9/assessments/12', + ); +}); + +// The title names the assessment, so it is the shortest route to it. Same absolute cross-instance url +// as the Actions link, for the same reason: a course id resolves only on its origin instance's host. +it('links the assessment title to its source assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByRole('link', { name: RECURSION_DRILL }), + ).toHaveAttribute('href', AUTHORING_URL); +}); + +// The column describes the ORIGIN, so a deleted original leaves it struck through and unlinked. The +// suffix is what carries the fact to anyone who cannot see the strikethrough. +it('strikes out and unlinks a deleted source assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toHaveClass('line-through'); + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + `${RECURSION_DRILL} ${DELETED_SUFFIX}`, + ]); + expect( + page.queryByRole('link', { name: RECURSION_DRILL }), + ).not.toBeInTheDocument(); +}); + +// The case that makes the rule worth stating: a REBUILT listing still has an authoring copy, and the +// cell must not quietly fall through to it. That copy is a different assessment in the marketplace +// container, so linking a column headed "Source assessment" at it would claim the origin survived. +it('leaves a rebuilt listing’s source assessment unlinked, though a copy exists', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + marketplaceHosted: true, + authoringAssessmentUrl: + 'http://preview.coursemology.org/courses/7/assessments/53', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toHaveClass('line-through'); + expect( + page.queryByRole('link', { name: RECURSION_DRILL }), + ).not.toBeInTheDocument(); + // The copy keeps its own entrance, so nothing became unreachable. + expect(page.getByRole('link', { name: OPEN_ACTION })).toHaveAttribute( + 'href', + 'http://preview.coursemology.org/courses/7/assessments/53', + ); +}); + +// A deleted course takes its assessment with it, so both columns mark — each with its own reason. +it('strikes out and unlinks a deleted source course', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, SOURCE_COLUMN)).toEqual([ + `Intro to Programming ${DELETED_SUFFIX}`, + ]); + expect( + page.queryByRole('link', { name: SOURCE_COURSE_NAME }), + ).not.toBeInTheDocument(); +}); + +// "Deleted" beside a live, serving listing reads as "broken" on its own, so each mark carries the +// sentence that says otherwise — and the two reasons are different, so they are two sentences. +it('explains on each mark what the deletion did and did not affect', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(ASSESSMENT_DELETED_HINT)).toHaveTextContent( + RECURSION_DRILL, + ); + expect(page.getByLabelText(COURSE_DELETED_HINT)).toHaveTextContent( + SOURCE_COURSE_NAME, + ); +}); + +// Deleting the origin no longer changes whether the listing is on the marketplace: the authoring copy +// is rebuilt automatically, so the listing goes on serving and goes on saying so. +it('keeps a listing published when its origin was deleted', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + marketplaceHosted: true, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText('Published')).toBeInTheDocument(); + expect(page.queryByText('Orphaned')).not.toBeInTheDocument(); +}); + +// "4 adoptions" raises "which courses?", and the listing page is the only place that answers it. +it('links a non-zero adoption count to the listing page', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ id: 42, adoptions: 4 }), + listingAt({ id: 43, title: ARRAYS_WARMUP, adoptions: 0 }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByRole('link', { name: '4' })).toHaveAttribute( + 'href', + '/admin/marketplace_listings/42', + ); + + // Zero stays plain text — there is no adoption list to go and look at, and the id beside it is + // already the unconditional entrance to the same page. + expect(columnTexts(page, ADOPTIONS_COLUMN)).toEqual(['4', '0']); + expect(page.queryByRole('link', { name: '0' })).not.toBeInTheDocument(); +}); + +// `Course` is tenanted by instance, so a course id resolves ONLY on its own instance's host — a +// relative link 404s for every listing published from another instance. +it('links the source course on its own instance host', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ sourceInstanceHost: 'satellite.coursemology.org' })], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: SOURCE_COURSE_NAME }); + expect(link).toHaveAttribute( + 'href', + '//satellite.coursemology.org/courses/9/assessments', + ); +}); + +// The exact column set, in order: the source course's teaching period was dropped from this table — +// an admin auditing listings never asked "which term?", and the column cost width the actions needed. +it('names the instance in its own column beside the source course', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + // Just "Instance" — adjacency to "Source course" carries whose instance it is. + expect( + page.getAllByRole('columnheader').map((header) => header.textContent), + ).toEqual([ + 'ID', + 'Original assessment', + 'Source course', + 'Instance', + 'Version', + 'Adoptions', + 'State', + 'Actions', + ]); + + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual([MAIN_CAMPUS]); +}); + +// The instance is only reachable on its own host, so the cell goes there rather than to a route on +// the admin's host that would resolve to the wrong deployment. +it('links the instance to its own host', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceInstanceName: SATELLITE_CAMPUS, + sourceInstanceHost: 'satellite.coursemology.org', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByRole('link', { name: SATELLITE_CAMPUS }), + ).toHaveAttribute('href', '//satellite.coursemology.org/'); +}); + +// Listings that were already orphaned when the column was introduced have no source course for the +// backfill to read the instance off, and there is no recovery path — so the row says so rather than +// silently omitting the origin. +it('renders the empty marker for a listing with no recorded instance', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + sourceCourseName: 'Retired Course', + sourceInstanceName: null, + sourceInstanceHost: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + // The denormalised course name survives the course deletion; the instance was never recorded, so + // the Instance column says so instead of leaving the origin blank. Neither is a link — there is + // nothing left to navigate to. + expect(columnTexts(page, SOURCE_COLUMN)).toEqual([ + `Retired Course ${DELETED_SUFFIX}`, + ]); + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual(['—']); + expect( + page.queryByRole('link', { name: 'Retired Course' }), + ).not.toBeInTheDocument(); +}); + +// Not a disabled placeholder either: nothing in the Actions cell mentions opening a copy that does +// not exist, so the cell holds only actions that can actually be taken. +it('hides the open action entirely while a listing has no authoring copy', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [assessmentDeleted, courseDeleted] = page.getAllByRole('row').slice(1); + + [assessmentDeleted, courseDeleted].forEach((row) => { + expect(within(row).queryByText(OPEN_ACTION)).not.toBeInTheDocument(); + // The restore and delete actions are what remains — the cell is not simply empty. + expect( + within(row).getByRole('button', { name: RESTORE_ACTION }), + ).toBeInTheDocument(); + }); +}); + +it('narrows the listings to those matching the searched assessment title', async () => { + const user = userEvent.setup(); + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt(), listingAt({ id: 2, title: ARRAYS_WARMUP })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await user.type(page.getByPlaceholderText(SEARCH_PLACEHOLDER), 'Arrays'); + + await waitFor(() => + expect(page.queryByText(RECURSION_DRILL)).not.toBeInTheDocument(), + ); + expect(page.getByText(ARRAYS_WARMUP)).toBeInTheDocument(); +}); + +// Search deliberately spans TWO columns and no more: title and source course. Source course is +// searchable instead of filterable because courses number in the hundreds — "listings from CS1010" is +// a text query, not a set selection. This test previously pinned search as title-ONLY; it now pins +// the widened scope, and still fails if a stray `searchable: true` reaches a third column. +it('searches assessment titles and source courses, not the other columns', async () => { + const user = userEvent.setup(); + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceCourseName: 'Data Structures', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const search = page.getByPlaceholderText(SEARCH_PLACEHOLDER); + const bothRows = [RECURSION_DRILL, ARRAYS_WARMUP]; + + // The source course of the second row only. + await user.type(search, 'Data Struct'); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ARRAYS_WARMUP]), + ); + + await user.clear(search); + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual(bothRows), + ); + + // The instance, which both rows carry: a two-value, low-cardinality dimension belongs behind the + // Instance column's FILTER, so putting it in the free-text box would invite typing "Main Campus" + // instead of filtering. Search must not match it even though the column is right beside the one it + // does match. + await user.type(search, MAIN_CAMPUS); + + await waitFor(() => expect(columnTexts(page, TITLE_COLUMN)).toEqual([])); + + await user.clear(search); + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual(bothRows), + ); + + // The served vintage, which both rows also carry: an unsearchable column must match neither. + await user.type(search, '24 Jul'); + + await waitFor(() => expect(columnTexts(page, TITLE_COLUMN)).toEqual([])); +}); + +it('sorts the listings by assessment title in both directions', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt(), listingAt({ id: 2, title: ARRAYS_WARMUP })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]); + + fireEvent.click(page.getByRole('button', { name: 'Original assessment' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RECURSION_DRILL, + ]), + ); + + fireEvent.click(page.getByRole('button', { name: 'Original assessment' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]), + ); +}); + +// Sorting groups the table by origin, which is the other half of what a per-course filter would have +// given — without a menu that grows with the table. +it('sorts the listings by source course in both directions', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ sourceCourseName: SOURCE_COURSE_NAME }), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceCourseName: 'Data Structures', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + fireEvent.click(page.getByRole('button', { name: 'Source course' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RECURSION_DRILL, + ]), + ); + + fireEvent.click(page.getByRole('button', { name: 'Source course' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]), + ); +}); + +// Sorting by instance comes free with the column, and groups the table by deployment. +it('sorts the listings by instance in both directions', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceInstanceName: SATELLITE_CAMPUS, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + fireEvent.click(page.getByRole('button', { name: 'Instance' })); + + await waitFor(() => + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual([ + MAIN_CAMPUS, + SATELLITE_CAMPUS, + ]), + ); + + fireEvent.click(page.getByRole('button', { name: 'Instance' })); + + await waitFor(() => + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual([ + SATELLITE_CAMPUS, + MAIN_CAMPUS, + ]), + ); +}); + +// A handful of instances, stable values, and the natural slice for an admin auditing one deployment's +// contributions. The "not recorded" bucket is real, not an omission: it is where listings that were +// already orphaned before the column existed live. +it('filters the listings by the source instance, including the unrecorded bucket', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceInstanceName: SATELLITE_CAMPUS, + sourceInstanceHost: 'satellite.coursemology.org', + }), + listingAt({ + id: 3, + title: RETIRED_QUIZ, + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + sourceInstanceName: null, + sourceInstanceHost: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await clickInstanceFilterItem(page, SATELLITE_CAMPUS); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ARRAYS_WARMUP]); + + await clickInstanceFilterItem(page, 'Instance not recorded'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + `${RETIRED_QUIZ} ${DELETED_SUFFIX}`, + ]); + + await clickInstanceFilterItem(page, 'Clear filter'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + `${RETIRED_QUIZ} ${DELETED_SUFFIX}`, + ]); +}); + +// Two independent menus in two different header cells: filtering by instance must not disturb the +// state filter, and neither may hijack the other's selection. +it('keeps the state and source instance filters independent', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + state: 'unlisted', + sourceInstanceName: SATELLITE_CAMPUS, + }), + listingAt({ id: 3, title: RETIRED_QUIZ, state: 'unlisted' }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await clickStateFilterItem(page, 'Unlisted'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); + + await clickInstanceFilterItem(page, MAIN_CAMPUS); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([RETIRED_QUIZ]); +}); + +it('sorts adoptions numerically rather than lexicographically', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ id: 1, title: 'Nine Adopters', adoptions: 9 }), + listingAt({ id: 2, title: 'Twelve Adopters', adoptions: 12 }), + listingAt({ id: 3, title: 'Four Adopters', adoptions: 4 }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText('Nine Adopters')).toBeInTheDocument(); + + fireEvent.click(page.getByRole('button', { name: 'Adoptions' })); + + await waitFor(() => + expect(columnTexts(page, ADOPTIONS_COLUMN)).toEqual(['12', '9', '4']), + ); + + fireEvent.click(page.getByRole('button', { name: 'Adoptions' })); + + await waitFor(() => + expect(columnTexts(page, ADOPTIONS_COLUMN)).toEqual(['4', '9', '12']), + ); +}); + +it('filters the listings by the states selected in the State column, and restores them all when the filter is cleared', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, state: 'unlisted' }), + listingAt({ + id: 3, + title: 'Legacy Quiz', + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await clickStateFilterItem(page, 'Unlisted'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ARRAYS_WARMUP]); + + await clickStateFilterItem(page, 'Published'); + + // Both states selected is every row — including the one whose origin was deleted, which is + // published like any other. + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + `Legacy Quiz ${DELETED_SUFFIX}`, + ]); + + await clickStateFilterItem(page, 'Clear filter'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + `Legacy Quiz ${DELETED_SUFFIX}`, + ]); +}); + +// The State column answers marketplace visibility and nothing else, so a deleted origin leaves no +// mark on it at all — the two Source columns carry that, and only they do. +it('tells the two deletion cases apart in the Source columns, not the State column', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + }), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [assessmentDeleted, courseDeleted] = page.getAllByRole('row').slice(1); + + // Both mark their assessment; only the second also lost its course. + expect( + within(assessmentDeleted).getByLabelText(ASSESSMENT_DELETED_HINT), + ).toBeInTheDocument(); + expect( + within(assessmentDeleted).queryByLabelText(COURSE_DELETED_HINT), + ).not.toBeInTheDocument(); + expect( + within(courseDeleted).getByLabelText(COURSE_DELETED_HINT), + ).toBeInTheDocument(); + + expect(columnTexts(page, STATE_COLUMN)).toEqual(['Published', 'Published']); +}); + +// The State column used to carry the whole "Orphaned — assessment deleted" phrase in one chip, which +// made it greedy enough to squeeze Actions to ~90px and wrap every label onto three lines. Actions now +// sit on ONE row and each label is unbreakable, so a row's height never depends on its action count. +it('keeps every action label on one line in a single row', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const openLink = await page.findByRole('link', { name: OPEN_ACTION }); + expect(openLink).toHaveClass('whitespace-nowrap'); + + const restoreButton = page.getByRole('button', { name: RESTORE_ACTION }); + expect(restoreButton).toHaveClass('whitespace-nowrap'); + + [openLink, restoreButton].forEach((action) => { + const row = action.closest('div'); + expect(row).toHaveClass('flex'); + expect(row).not.toHaveClass('flex-wrap'); + }); + + // Fixed slot order — list/unlist, then restore, then delete — so no action moves between rows. + // Scoped to this row: every row carries the visibility and delete actions now, published included. + const row = restoreButton.closest('tr')!; + const actions = Array.from(restoreButton.parentElement!.children); + expect(actions[0]).toHaveTextContent('Unlist'); + expect(actions[1]).toBe(restoreButton); + expect(actions[2]).toContainElement( + within(row).getByTestId('DeleteIconButton'), + ); +}); + +// The reversible half of the maintenance pair. It is admin-side and keyed on the listing id, because +// the course-side unlist resolves the listing through its authoring assessment — which a listing +// whose source was deleted no longer has, so that path cannot reach exactly the rows that need it. +it('unlists a published listing and refetches', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + mock.onPatch(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: 'Unlist' })); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/stops appearing in the marketplace/), + ).toBeVisible(); + // Unlisting is what has to happen before a deletion, so the dialog says so. + expect(within(dialog).getByText(/reversible/)).toBeVisible(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Unlist' })); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(JSON.parse(mock.history.patch[0].data)).toEqual({ published: false }); + // The row's state changes server-side, and with it whether the row can be deleted at all. + await waitFor(() => expect(indexFetchCount()).toBe(2)); +}); + +it('lists an unlisted listing again', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted' })], + }); + mock.onPatch(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: 'List' })); + + const dialog = await page.findByRole('dialog'); + // Re-listing serves the version already held — it must never read as publishing a new one. + expect( + within(dialog).getByText(/serving the version it already holds/), + ).toBeVisible(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'List' })); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(JSON.parse(mock.history.patch[0].data)).toEqual({ published: true }); +}); + +// One button, flipping with the state it reads: offering both at once would leave one permanently +// inert, and a listing is either on the marketplace or it is not. +it('offers only the opposite action on each row', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, state: 'unlisted' }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [published, unlisted] = page.getAllByRole('row').slice(1); + + expect( + within(published).getByRole('button', { name: 'Unlist' }), + ).toBeInTheDocument(); + expect( + within(published).queryByRole('button', { name: 'List' }), + ).not.toBeInTheDocument(); + expect( + within(unlisted).getByRole('button', { name: 'List' }), + ).toBeInTheDocument(); +}); + +it('surfaces the server’s reason when it refuses to list', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ state: 'unlisted', currentVersionPublishedAt: null }), + ], + }); + mock.onPatch(`${INDEX_URL}/1`).reply(422, { + errors: ['This listing has no published version to serve.'], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: 'List' })); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'List', + }), + ); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'This listing has no published version to serve.', + ), + ); +}); + +// Deletion follows `Listing#purgeable?`: enabled wherever the listing is off the marketplace — +// orphaned or unlisted — and never on a published one, which must be unlisted first. The button is +// on every row either way, so its tooltip can state that rule. Adoption count gates nothing, so +// every purgeable row's delete action is enabled regardless of how many courses adopted it. +it('offers permanent deletion off the marketplace only, regardless of adoption history', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 4, + }), + listingAt({ + id: 3, + title: RETIRED_QUIZ, + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + // Unlisted keeps its source assessment, so unlike the orphans it still carries an open action. + listingAt({ + id: 4, + title: 'Unlisted Quiz', + state: 'unlisted', + adoptions: 0, + }), + listingAt({ + id: 5, + title: 'Unlisted And Adopted', + state: 'unlisted', + adoptions: 2, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [published, adopted, unadopted, unlisted, unlistedAdopted] = page + .getAllByRole('row') + .slice(1); + + // A published listing is unlisted, never deleted — so the icon is present but inert, and says why + // on hover rather than leaving the admin to guess at a control that simply is not there. + expect(within(published).getByTestId('DeleteIconButton')).toBeDisabled(); + expect( + within(published).getByLabelText(DELETE_BLOCKED_TOOLTIP), + ).toBeInTheDocument(); + + [adopted, unadopted, unlisted, unlistedAdopted].forEach((row) => { + expect(within(row).getByTestId('DeleteIconButton')).toBeEnabled(); + expect( + within(row).getByLabelText('Delete permanently'), + ).toBeInTheDocument(); + }); +}); + +// A disabled MUI IconButton swallows pointer events, so the tooltip only fires because DeleteButton +// wraps it in a `span` — and the whole point of keeping the icon is that hovering it explains the +// rule. Clicking must still do nothing: no confirm dialog, no request. +it('opens no confirm dialog from the disabled delete on a published listing', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + expect(page.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mock.history.delete).toHaveLength(0); +}); + +// Adoption count is decision-relevant even though it no longer disables anything: a deliberate +// deletion needs the facts at the moment of deciding, so the confirm dialog states how many courses +// adopted the listing and that their copies are unaffected — layered on top of whichever of the +// orphaned/unlisted messages applies, not replacing it. +it('warns in the confirm dialog how many courses adopted the listing, layered on the base message', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 3, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + // The base orphaned message is still present … + expect( + within(dialog).getByText(/all of its versions and the snapshots/), + ).toBeVisible(); + // … with the adoption warning layered on top, not swapped in for it. + expect( + within(dialog).getByText( + /3 courses have adopted this listing\. Their existing copies will not be affected, but the adoption history will be destroyed\./, + ), + ).toBeVisible(); +}); + +it('does not show an adoption warning in the confirm dialog when nothing adopted the listing', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted', adoptions: 0 })], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).queryByText(/adopted this listing/), + ).not.toBeInTheDocument(); +}); + +// The two cases destroy different things, so they cannot share one warning: an orphan has already +// lost its source, whereas an unlisted listing keeps it — and telling someone to unlist a listing +// that is already unlisted is no advice at all. +it('warns that an unlisted deletion spares the source assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted', adoptions: 0 })], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/all of its versions and the snapshots/), + ).toBeVisible(); + expect( + within(dialog).getByText( + /source assessment is not affected and can be published again/, + ), + ).toBeVisible(); + expect( + within(dialog).queryByText(/unlist it instead/), + ).not.toBeInTheDocument(); +}); + +it('warns what a permanent deletion destroys, then deletes and refetches', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/all of its versions and the snapshots/), + ).toBeVisible(); + expect(within(dialog).getByText(/cannot be undone/)).toBeVisible(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${INDEX_URL}/1`); + // The row is gone only because the list refetched — the client never patches it locally. + await waitFor(() => expect(indexFetchCount()).toBe(2)); +}); + +it('surfaces the server’s reason when it refuses a permanent deletion', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock.onDelete(`${INDEX_URL}/1`).reply(422, { + errors: ['This listing has been adopted by other courses.'], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'Delete', + }), + ); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'This listing has been adopted by other courses.', + ), + ); +}); + +// There is no destination to choose: the copy always lands in the marketplace's own container, so +// the dialog is a plain confirm. +it('restores without asking for a destination course', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock + .onPost(`${INDEX_URL}/1/restore_authoring`) + .reply(200, { status: 'submitted', jobUrl: UNWATCHED_JOB_URL }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + + const dialog = await page.findByRole('dialog'); + expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Rebuild' })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + // No destination is sent at all — the server owns the only correct destination. + expect(mock.history.post[0].data).toBeUndefined(); +}); + +it('tells the admin the copy lands in the marketplace container', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/marketplace's own container course/), + ).toBeVisible(); + // The picker is gone for BOTH orphan states — a deleted origin course no longer changes anything. + expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument(); +}); + +it('toasts a link to the restored assessment and refetches once the job completes', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock + .onPost(`${INDEX_URL}/1/restore_authoring`) + .reply(200, { status: 'submitted', jobUrl: COMPLETED_JOB_URL }); + jobsMock + .onGet(COMPLETED_JOB_URL) + .reply(200, { status: 'completed', redirectUrl: RESTORED_URL }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'Rebuild', + }), + ); + + // pollJob polls every 2s — longer than waitFor's 1s default. + await waitFor(() => expect(toast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + + const message = (toast.success as unknown as jest.Mock).mock.calls[0][0]; + const toasted = render(
{message}
); + + expect( + await toasted.findByText( + /Source assessment rebuilt in the marketplace container\./, + ), + ).toBeInTheDocument(); + expect( + toasted.getByRole('link', { name: 'View assessment' }), + ).toHaveAttribute('href', RESTORED_URL); + + // The listing's state and authoring url both change server-side, so the list must refetch. + await waitFor(() => expect(indexFetchCount()).toBe(2)); +}, 10000); + +it('reports a failed restore job without claiming success', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock + .onPost(`${INDEX_URL}/1/restore_authoring`) + .reply(200, { status: 'submitted', jobUrl: ERRORED_JOB_URL }); + jobsMock.onGet(ERRORED_JOB_URL).reply(200, { status: 'errored' }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'Rebuild', + }), + ); + + await waitFor(() => expect(toast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(toast.error).toHaveBeenCalledWith( + 'Could not rebuild the source assessment.', + ); + expect(toast.success).not.toHaveBeenCalled(); +}, 10000); + +it('offers no restore for an orphan with no version to restore from', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + currentVersionPublishedAt: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + // Deletion is still on offer — it is the version, not the orphan state, that restore needs. + expect(await page.findByTestId('DeleteIconButton')).toBeInTheDocument(); + expect( + page.queryByRole('button', { name: RESTORE_ACTION }), + ).not.toBeInTheDocument(); +}); + +// A rebuilt listing keeps naming its ORIGIN course in the Source course column — that provenance is a +// historical fact the rebuild deliberately leaves alone — so without this marker its row is +// indistinguishable from a listing whose source assessment really is still in that course. +it('marks a marketplace-hosted listing apart from one with its own source course', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + marketplaceHosted: true, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [ownSource, hosted] = page.getAllByRole('row').slice(1); + + // Both are on the marketplace, so both keep the same state chip: the marker is what separates them. + expect(within(ownSource).getByText('Published')).toBeInTheDocument(); + expect(within(hosted).getByText('Published')).toBeInTheDocument(); + + expect(within(hosted).getByText(MARKETPLACE_HOSTED)).toBeInTheDocument(); + expect( + within(ownSource).queryByText(MARKETPLACE_HOSTED), + ).not.toBeInTheDocument(); +}); + +it('explains on the marker what marketplace-hosted means', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ marketplaceHosted: true })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(MARKETPLACE_HOSTED_HINT)).toHaveTextContent( + MARKETPLACE_HOSTED, + ); +}); + +// Visibility and authoring location are independent axes, and the marker is a facet rather than a +// state value precisely so this holds: a marketplace-hosted listing that is later unlisted still +// reports both facts, and each is separately filterable. +it('keeps the state chip when a marketplace-hosted listing is unlisted', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted', marketplaceHosted: true })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText('Unlisted')).toBeInTheDocument(); + expect(page.getByText(MARKETPLACE_HOSTED)).toBeInTheDocument(); +}); + +it('filters on the marketplace-hosted facet independently of the state values', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, marketplaceHosted: true }), + listingAt({ + id: 3, + title: RETIRED_QUIZ, + state: 'unlisted', + marketplaceHosted: true, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + // Cuts across published and unlisted alike — which is the point of asking for it as its own facet. + await clickStateFilterItem(page, MARKETPLACE_HOSTED); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); + + await clickStateFilterItem(page, MARKETPLACE_HOSTED); + await clickStateFilterItem(page, 'Unlisted'); + + // The state values still filter on state alone: the hosted published row is excluded here. + expect(columnTexts(page, TITLE_COLUMN)).toEqual([RETIRED_QUIZ]); + + await clickStateFilterItem(page, 'Clear filter'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); +}); + +// The complement, which is the half an admin auditing who-owns-what actually needs: "which listings +// still depend on course staff". Labelled as a negation and NOT chipped on the rows — it is the +// ordinary state of the world, and a second noun beside Published/Unlisted would read as a state. +it('filters on the negation of the marketplace-hosted facet', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, marketplaceHosted: true }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + // Only the exception is marked on the row; the ordinary case carries no chip of its own. + expect(page.getAllByText(MARKETPLACE_HOSTED)).toHaveLength(1); + + await clickStateFilterItem(page, 'Not marketplace-hosted'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([RECURSION_DRILL]); + + await clickStateFilterItem(page, MARKETPLACE_HOSTED); + + // Both halves selected is every row — the pair is exhaustive. + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]); +}); + +it('shows the empty state when there are no listings', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [] }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByText('No assessments have been published yet.'), + ).toBeInTheDocument(); +}); + +// The id is a primary key, not a position: deleting a listing renumbers nothing. It is surfaced +// because it is the only thing that separates two listings sharing a title and a source course, +// and because the container's version chips name listings by it. +it('shows each listing id', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt({ id: 42 })] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, ID_COLUMN)).toEqual(['42']); +}); + +it('links the id to the listing history page', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt({ id: 42 })] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByRole('link', { name: '42' })).toHaveAttribute( + 'href', + '/admin/marketplace_listings/42', + ); +}); + +// The Version cell is only a link when a version exists, so before this column a listing that had +// never published one had NO route to its own history page from anywhere in the application. +it('links the id even when the listing has never published a version', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ id: 42, currentVersionPublishedAt: null })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByRole('link', { name: '42' })).toHaveAttribute( + 'href', + '/admin/marketplace_listings/42', + ); + expect(columnTexts(page, VERSION_COLUMN)).toEqual(['—']); +}); diff --git a/client/app/routers/courseless/systemAdmin.tsx b/client/app/routers/courseless/systemAdmin.tsx index 2e1bba29aac..ef4401cd859 100644 --- a/client/app/routers/courseless/systemAdmin.tsx +++ b/client/app/routers/courseless/systemAdmin.tsx @@ -78,6 +78,28 @@ const systemAdminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'marketplace_listings', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceListingsIndex' */ + 'bundles/system/admin/admin/pages/MarketplaceListingsIndex' + ) + ).default, + }), + }, + { + path: 'marketplace_listings/:listingId', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceListingShow' */ + 'bundles/system/admin/admin/pages/MarketplaceListingShow' + ) + ).default, + }), + }, { path: 'get_help', lazy: async (): Promise> => ({ diff --git a/client/app/types/system/courses.ts b/client/app/types/system/courses.ts index f3d2e8017d9..61c1b48ead7 100644 --- a/client/app/types/system/courses.ts +++ b/client/app/types/system/courses.ts @@ -8,6 +8,8 @@ export interface CourseListData { createdAt: string; activeUserCount: number; userCount: number; + /** True for the hidden marketplace preview container, which no course picker may offer. */ + preview: boolean; instance: InstanceMiniEntity; owners: UserBasicMiniEntity[]; } diff --git a/client/app/types/system/marketplaceListings.ts b/client/app/types/system/marketplaceListings.ts new file mode 100644 index 00000000000..5e75c571ab6 --- /dev/null +++ b/client/app/types/system/marketplaceListings.ts @@ -0,0 +1,94 @@ +/** + * Marketplace VISIBILITY, and nothing else. The origin's fate is reported by `sourceAssessmentDeleted` + * / `sourceCourseDeleted` instead of by an `orphaned` state value, because the two cross: a listing + * whose source assessment was deleted is rebuilt into the marketplace container and goes on being + * published, so one enum cannot carry both facts. + */ +export type MarketplaceListingState = 'published' | 'unlisted'; + +export interface MarketplaceListingAdminData { + id: number; + title: string | null; + currentVersionPublishedAt: string | null; + lastPublishedAt: string | null; + adoptions: number; + sourceCourseId: number | null; + sourceCourseName: string | null; + /** + * The instance the source course belonged to. Null for listings that were already orphaned when + * the column was introduced — there is nothing left on the row that identifies their origin. + */ + sourceInstanceName: string | null; + /** The origin instance's host: a course id only resolves there, never on the admin's own host. */ + sourceInstanceHost: string | null; + state: MarketplaceListingState; + /** + * Whether the authoring copy lives in the marketplace's own container course rather than in a course + * somebody owns — true after a rebuild, and for anything authored in the container directly. + * + * Orthogonal to `state`, which reports marketplace visibility. It is a separate field rather than a + * fifth state value because the two axes cross: a marketplace-hosted listing can also be unlisted. + * The provenance fields above keep naming the ORIGIN course after a rebuild, so this is the only + * thing on the row that says where the copy an admin would edit actually is. + */ + marketplaceHosted: boolean; + /** + * Whether the assessment this listing was published FROM has been deleted. Outlives the repair: + * the authoring copy is rebuilt in the container, so this stays true while `state` reads + * `published` — which is why it cannot be a state value. + */ + sourceAssessmentDeleted: boolean; + /** Whether the origin course has been deleted. Its denormalised name survives it. */ + sourceCourseDeleted: boolean; + authoringAssessmentUrl: string | null; +} + +export interface MarketplaceListingVersionData { + /** + * When this version's CONTENT was published, not when anyone copied it. This IS the version's + * identity — there is no ordinal. + */ + publishedAt: string | null; + publisherName: string | null; + isCurrent: boolean; + /** + * Absolute URL into the container course on the preview instance. Null when the snapshot no + * longer resolves — a version row without a link rather than a broken one. + */ + snapshotUrl: string | null; +} + +export interface MarketplaceListingAdoptionData { + id: number; + destinationCourseId: number | null; + destinationCourseName: string | null; + /** Adopters span instances, and a course id only resolves on its own instance's host. */ + destinationCourseHost: string | null; + adoptedVersionAt: string | null; + adoptedAt: string | null; + /** The snapshot of the version this course holds, so an admin can inspect what it actually got. */ + snapshotUrl: string | null; +} + +/** Provenance, full version history and every adoption for one listing. Read-only. */ +export interface MarketplaceListingDetailData { + id: number; + title: string | null; + currentVersionPublishedAt: string | null; + state: MarketplaceListingState; + /** See `MarketplaceListingAdminData.marketplaceHosted`. */ + marketplaceHosted: boolean; + /** See `MarketplaceListingAdminData.sourceAssessmentDeleted`. */ + sourceAssessmentDeleted: boolean; + /** See `MarketplaceListingAdminData.sourceCourseDeleted`. */ + sourceCourseDeleted: boolean; + /** Absolute url of the copy an admin would edit, or null while the listing has none. */ + authoringAssessmentUrl: string | null; + sourceCourseId: number | null; + sourceCourseName: string | null; + sourceInstanceName: string | null; + sourceInstanceHost: string | null; + /** Ascending by publish date. Empty for a listing that has never been published. */ + versions: MarketplaceListingVersionData[]; + adoptions: MarketplaceListingAdoptionData[]; +} From e8e0de59d3750e5abd825a7abef3087b98cef402 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:34:03 +0800 Subject: [PATCH 13/28] feat(marketplace): launch a hands-on preview in the sandbox container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `PreviewLaunchService` and the `launch_preview` endpoint: a previewer is enrolled as a manager of the singleton container course and handed the attempt url for the listing's served snapshot, on the preview host. No copy step — the snapshot the marketplace already serves is what a duplicate would give an adopter, so a preview cannot drift from the adopted copy. Also adds `Listing.serving_assessment?`, which the sandbox lock uses to tell a handed-out snapshot from the rest of the container, and moves the container's description into locales so the sandbox explainer can be written as HTML. --- .../marketplace/listings_controller.rb | 21 ++++- .../course/assessment/marketplace/listing.rb | 20 ++++- .../marketplace/preview_container_service.rb | 30 ++++++- .../marketplace/preview_launch_service.rb | 52 ++++++++++++ .../en/course/assessment/marketplace.yml | 23 ++++++ .../ko/course/assessment/marketplace.yml | 15 ++++ .../zh/course/assessment/marketplace.yml | 15 ++++ config/routes.rb | 1 + .../marketplace/listings_controller_spec.rb | 51 +++++++++++- .../assessment/marketplace/listing_spec.rb | 38 +++++++++ .../preview_container_service_spec.rb | 12 +++ .../preview_launch_service_spec.rb | 79 +++++++++++++++++++ 12 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 app/services/course/assessment/marketplace/preview_launch_service.rb create mode 100644 config/locales/en/course/assessment/marketplace.yml create mode 100644 config/locales/ko/course/assessment/marketplace.yml create mode 100644 config/locales/zh/course/assessment/marketplace.yml create mode 100644 spec/services/course/assessment/marketplace/preview_launch_service_spec.rb diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index c957b07212c..7dbe4b87d0b 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -6,7 +6,7 @@ def index ActsAsTenant.without_tenant do # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on the # acting-as record. Reads go through the current version snapshot, never the authoring copy: the - # marketplace serves what a duplicate would give you (design §4.2). `where.not(current_version_id: + # marketplace serves what a duplicate would give you. `where.not(current_version_id: # nil)` guards a published listing with no snapshot, whose nil `current_version` would 500 browse. @listings = Course::Assessment::Marketplace::Listing.published. where.not(current_version_id: nil). @@ -32,7 +32,7 @@ def show includes(current_version: :assessment).find_by(id: params[:id]) raise CanCan::AccessDenied unless @listing - # The SNAPSHOT, never the authoring copy (design §4.2). + # The SNAPSHOT, never the authoring copy. @assessment = @listing.current_version&.assessment raise CanCan::AccessDenied unless @assessment @@ -42,6 +42,23 @@ def show end end + def launch_preview + ActsAsTenant.without_tenant do + @listing = Course::Assessment::Marketplace::Listing.published. + includes(current_version: :assessment).find_by(id: params[:id]) + raise CanCan::AccessDenied unless @listing + + # A preview rehearses the SNAPSHOT, never the authoring copy — the same row a duplicate would + # copy. Guarded here rather than in the service so a published listing with no + # snapshot is denied instead of crashing on a nil deep inside provisioning. + raise CanCan::AccessDenied unless @listing.current_version&.assessment + + authorize!(:preview_in_marketplace, @listing) + url = Course::Assessment::Marketplace::PreviewLaunchService.launch(@listing, current_user) + render json: { url: url } + end + end + private def authorize_access! diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb index f48fa692c15..f38ca865a49 100644 --- a/app/models/course/assessment/marketplace/listing.rb +++ b/app/models/course/assessment/marketplace/listing.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Course::Assessment::Marketplace::Listing < ApplicationRecord # The mutable authoring copy — the origin-course assessment. Nullable: the listing outlives - # deletion of its origin (design §4.3). What the marketplace serves is `current_version.assessment`. + # deletion of its origin. What the marketplace serves is `current_version.assessment`. belongs_to :authoring_assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing, optional: true belongs_to :publisher, class_name: 'User', inverse_of: false @@ -45,6 +45,24 @@ def self.for_admin_index end end + # Whether `assessment_id` is the snapshot the marketplace currently serves for some listed listing + # — the only thing in the container course a previewer was ever handed a URL to (see + # `PreviewLaunchService`). Everything else there is a superseded snapshot, a restored authoring + # working copy, or the snapshot of a delisted listing, and container ids are guessable, so the + # preview sandbox lock vets the assessment with this rather than trusting a hidden index. + # + # Tenant-free without needing `without_tenant`: neither this table nor the versions table is + # `acts_as_tenant`, and the join never reaches `Course`. + # + # @param [Integer] assessment_id + # @return [Boolean] + def self.serving_assessment?(assessment_id) + return false if assessment_id.blank? + + published.joins(:current_version). + exists?(course_assessment_marketplace_listing_versions: { assessment_id: assessment_id }) + end + # An orphaned listing lost its authoring copy (the origin assessment was deleted) but still # serves its last snapshot. Deliberately separate from `admin_state`, which is a display concern. # @return [Boolean] diff --git a/app/services/course/assessment/marketplace/preview_container_service.rb b/app/services/course/assessment/marketplace/preview_container_service.rb index 2312331c1bd..0f58aec60a6 100644 --- a/app/services/course/assessment/marketplace/preview_container_service.rb +++ b/app/services/course/assessment/marketplace/preview_container_service.rb @@ -26,6 +26,22 @@ def preview_instance end end + # Whether `instance` is the dedicated preview instance. The preview sandbox lock keys off this + # rather than off `Course#preview`, because it also has to confine a previewer on the courseless + # pages of this instance (`/courses`, `/role_requests`), where there is no course to read a flag + # from — and the container is the only course here, so the instance is the wider of two circles + # that enclose the same content. + # + # Compares the raw column, not `Instance#host`: that accessor rewrites `coursemology.org` to the + # environment's default host (see `preview_instance`), so in development the preview tenant reads + # back as `preview.lvh.me:8080`. `preview_instance` looks the row up by the raw value too. + # + # @param [Instance, nil] instance + # @return [Boolean] + def preview_instance?(instance) + instance&.read_attribute(:host) == PREVIEW_INSTANCE_HOST + end + # @return [Course] the single `preview: true` container course in the preview instance. def container_course instance = preview_instance @@ -36,15 +52,27 @@ def container_course private + # The description is the explainer rendered on this course's home page (see `CourseShow`), which + # is otherwise blank: the sandbox has no announcements, todos or activity of its own. Its audience + # is now the administrators who curate the container — `ApplicationPreviewSandboxConcern` keeps + # previewers off the home page, and `PreviewCourseBanner` tells them the same thing on the + # submission page they are actually landed on. Pinned to `:en` like `Course::Mailer#invite`, + # because the container is a cross-instance singleton and its column can only ever hold one + # language — whoever happens to trigger creation must not be the one who decides which. + # # `published/gamified/enrollable: false` keep the container out of every listing, level and # self-enrolment path: it holds the marketplace's snapshots, so it must never surface as a # course in its own right. Previewers are attached to it explicitly, one at a time. def create_container_course(instance) + description = I18n.with_locale(:en) do + I18n.t('course.assessment.marketplace.preview_container.description_html') + end + User.with_stamper(User.system) do Course.create!( instance: instance, title: PREVIEW_COURSE_TITLE, - description: 'System container for marketplace version snapshots and hands-on previews.', + description: description, preview: true, published: false, gamified: false, diff --git a/app/services/course/assessment/marketplace/preview_launch_service.rb b/app/services/course/assessment/marketplace/preview_launch_service.rb new file mode 100644 index 00000000000..63ccd2d173e --- /dev/null +++ b/app/services/course/assessment/marketplace/preview_launch_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# Attaches a previewer to the marketplace sandbox and returns the absolute attempt URL for the +# listing's served snapshot, on the preview host. Idempotent on (listing, user). +# +# There is no copy step here, deliberately. The listing's current version snapshot ALREADY lives in +# the container course — `PublishService#snapshot_into_container` puts it there — and that same row is +# what a duplicate would hand an adopter. Previewing it is therefore faithful by construction: a +# preview cannot drift from the adopted copy, because they are the same assessment. +# Duplicating again per preview would reintroduce exactly that drift. +# +# Preconditions: the listing is published and has a `current_version`. `ListingsController +# #launch_preview` enforces both before calling — a listing with no snapshot has nothing to rehearse. +# +# This service performs NO authorization and must not. It runs with system privileges deliberately: the +# `preview` content-freeze (Course::AssessmentMarketplaceAbilityComponent) would otherwise block the very +# provisioning it depends on. The caller authorizes — see ListingsController#launch_preview. +class Course::Assessment::Marketplace::PreviewLaunchService + class << self + # Route helpers are not available on a plain class by default; delegate rather than include the + # whole url_helpers module, matching `CikgoTaskCompletionConcern#submission_url`'s approach. + delegate :course_assessment_attempt_path, to: 'Rails.application.routes.url_helpers' + + def launch(listing, user) + ActsAsTenant.without_tenant do + instance = Course::Assessment::Marketplace::PreviewContainerService.preview_instance + course = Course::Assessment::Marketplace::PreviewContainerService.container_course + snapshot = listing.current_version.assessment + ensure_enrolment(user, instance, course) + attempt_url(instance, course, snapshot) + end + end + + private + + def ensure_enrolment(user, instance, course) + ActsAsTenant.with_tenant(instance) do + InstanceUser.find_or_create_by!(user: user) { |instance_user| instance_user.role = :normal } + end + + course.course_users.find_or_create_by!(user: user) do |course_user| + course_user.name = user.name + course_user.role = :manager + course_user.creator = User.system + course_user.updater = User.system + end + end + + def attempt_url(instance, course, snapshot) + "#{instance.redirect_uri}#{course_assessment_attempt_path(course, snapshot)}" + end + end +end diff --git a/config/locales/en/course/assessment/marketplace.yml b/config/locales/en/course/assessment/marketplace.yml new file mode 100644 index 00000000000..8e0897bee54 --- /dev/null +++ b/config/locales/en/course/assessment/marketplace.yml @@ -0,0 +1,23 @@ +en: + course: + assessment: + marketplace: + preview_container: + description_html: | +

Welcome to the Marketplace Preview Sandbox — a throwaway course for trying out + assessments shared on the Assessment Marketplace before you adopt one into your own course.

+

You are here because you launched a hands-on preview. The assessment opens in its own tab, where + you can answer, submit and grade it exactly as your students and graders would.

+
    +
  • Nothing here is real. Attempts, grades and points stay in this sandbox and + never reach any course — yours or the author's.
  • +
  • The content is read-only. Each assessment here is a frozen copy of its + listing and cannot be edited from the sandbox.
  • +
  • Attempts are cleared automatically once they have been left alone for a + while. To start over on an assessment you have already submitted, use the + Reset submission button in the banner above your submission.
  • +
  • Sidebar navigation is disabled. The sandbox exists only to rehearse the + assessment you opened.
  • +
+

When you are ready to use an assessment for real, head back to your own course and duplicate it + from the Marketplace.

diff --git a/config/locales/ko/course/assessment/marketplace.yml b/config/locales/ko/course/assessment/marketplace.yml new file mode 100644 index 00000000000..3e4aadcd6a4 --- /dev/null +++ b/config/locales/ko/course/assessment/marketplace.yml @@ -0,0 +1,15 @@ +ko: + course: + assessment: + marketplace: + preview_container: + description_html: | +

마켓플레이스 미리보기 샌드박스에 오신 것을 환영합니다. 평가 마켓플레이스에 공개된 평가를 내 코스에 도입하기 전에 직접 사용해 볼 수 있는 임시 코스입니다.

+

직접 해보기 미리보기를 실행하셨기 때문에 이 페이지에 오셨습니다. 평가는 별도의 탭에서 열리며, 학생과 채점자가 하는 것과 똑같이 답안을 작성하고 제출하고 채점할 수 있습니다.

+
    +
  • 이곳의 어떤 것도 실제가 아닙니다. 답안, 성적, 점수는 모두 이 샌드박스 안에만 남으며, 내 코스든 작성자의 코스든 어떤 코스에도 반영되지 않습니다.
  • +
  • 콘텐츠는 읽기 전용입니다. 이곳의 각 평가는 마켓플레이스 항목의 고정된 복사본이므로 샌드박스에서 수정할 수 없습니다.
  • +
  • 답안은 한동안 사용하지 않으면 자동으로 삭제됩니다. 이미 제출한 평가를 처음부터 다시 풀려면 제출 화면 상단 배너에 있는 제출 재설정 버튼을 사용하세요.
  • +
  • 사이드바 내비게이션은 비활성화되어 있습니다. 이 샌드박스는 열어 본 평가를 사용해 보는 용도로만 존재합니다.
  • +
+

평가를 실제로 사용할 준비가 되면 내 코스로 돌아가 마켓플레이스에서 복제하세요.

diff --git a/config/locales/zh/course/assessment/marketplace.yml b/config/locales/zh/course/assessment/marketplace.yml new file mode 100644 index 00000000000..f2f731bc135 --- /dev/null +++ b/config/locales/zh/course/assessment/marketplace.yml @@ -0,0 +1,15 @@ +zh: + course: + assessment: + marketplace: + preview_container: + description_html: | +

欢迎来到市场预览沙盒——一个用于试用的临时课程,你可以在把评估市场上共享的评估采用到自己的课程之前,先在这里试用一下。

+

你会来到这里,是因为你启动了动手预览。评估会在单独的标签页中打开,你可以像你的学生和评分者那样作答、提交和评分。

+
    +
  • 这里的一切都不是真实的。作答、成绩和积分都只留在此沙盒中,绝不会进入任何课程——无论是你的还是作者的。
  • +
  • 内容为只读。这里的每个评估都是其市场条目的冻结副本,无法从沙盒中编辑。
  • +
  • 作答会在闲置一段时间后自动清除。若想重新试做已提交的评估,请在提交页面上方的横幅中点击重置提交按钮。
  • +
  • 侧边栏导航已停用。此沙盒的唯一用途,就是试用你所打开的那个评估。
  • +
+

当你准备正式使用某个评估时,请回到自己的课程,从市场中复制它。

diff --git a/config/routes.rb b/config/routes.rb index 005b2ac8b9d..395ef198a68 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -637,6 +637,7 @@ get 'marketplace' => 'listings#index', as: :marketplace resources :listings, only: [:show], path: 'marketplace/listings' do post 'duplicate', on: :collection + post 'launch_preview', on: :member resources :questions, only: [:show] end end diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index e03120ae95f..1520b8f2b9a 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -227,7 +227,7 @@ before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } - # Published through the real service: `#show` renders the container SNAPSHOT (design §4.2), + # Published through the real service: `#show` renders the container SNAPSHOT, # so the question must exist on the snapshot, not just the authoring copy. let!(:listing) do assessment = create(:assessment, course: create(:course)) @@ -239,6 +239,9 @@ get :show, params: { course_id: course, id: listing.id, format: :json } expect(response).to have_http_status(:ok) body = response.parsed_body + # Must be the listing's own id, not the assessment's — TryItHandsOnButton and + # DuplicateConfirmation POST back with this id to endpoints keyed on `Listing#id`. + expect(body['id']).to eq(listing.id) expect(body).to include('title', 'gradingMode', 'showMcqMrqSolution', 'showRubricToStudents', 'gradedTestCases') # The listing preview reports the human-readable question type, matching the per-question chips. readable_type = I18n.t('course.assessment.question.multiple_responses.question_type.multiple_choice') @@ -312,6 +315,52 @@ expect(row['questionCount']).to eq(1) end end + + describe 'POST #launch_preview' do + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + + let!(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + + it 'provisions the preview and returns the attempt url' do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + expect(response).to have_http_status(:ok) + expect(response.parsed_body['url']).to be_present + end + + context 'when the manager is not on the allow-list' do + # Outer `before` above already granted the rule; wipe it so the marketplace gate itself + # (not the base :read gate, which a manager already passes) is what denies this request. + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + it 'denies access' do + expect do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when the listing is unpublished' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: false) } + + it 'denies access' do + expect do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + # A preview attempts the snapshot, so a published listing that has none has nothing to + # rehearse. Denied at the controller rather than left to fail on a nil inside provisioning. + context 'when the listing has no snapshot' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: true) } + + it 'denies access' do + expect do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + end end # Cross-instance: a listing published in another instance is visible. diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb index 46aff30f530..5a7a7d7648b 100644 --- a/spec/models/course/assessment/marketplace/listing_spec.rb +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -120,6 +120,44 @@ end end + # The preview sandbox lock's only per-assessment check (see + # spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb), so each way of + # being "in the container but not served" is worth stating outright. + describe '.serving_assessment?' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } + let(:served) { listing.current_version.assessment } + + it 'is true for the assessment the current version points at' do + expect(described_class).to be_serving_assessment(served.id) + end + + it 'is false for an assessment no version points at' do + expect(described_class).not_to be_serving_assessment(listing.authoring_assessment.id) + end + + it 'is false for a superseded version' do + superseded = listing.current_version + newer = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: 1.hour.from_now, + published_by: listing.publisher) + listing.update!(current_version: newer) + + expect(described_class).not_to be_serving_assessment(superseded.assessment_id) + expect(described_class).to be_serving_assessment(newer.assessment_id) + end + + it 'is false once the listing is unlisted' do + listing.update!(published: false) + expect(described_class).not_to be_serving_assessment(served.id) + end + + it 'is false for a blank id, without querying for one' do + expect(described_class).not_to be_serving_assessment(nil) + end + end + describe 'maintenance predicates' do let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } diff --git a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb index dc5af625717..6e7284483af 100644 --- a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb +++ b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb @@ -82,6 +82,18 @@ expect(Course.containing_user(other_user)).not_to include(container) end end + + # The explainer is the only thing on the sandbox home page — the container has no + # announcements, todos or activity of its own, and its sidebar is de-linked — so a container + # created without it is a blank page. Asserted by content rather than against the locale + # value itself, which would only restate the service's own lookup. + it 'is created with the sandbox explainer as its description' do + container = described_class.container_course + + expect(container.description).to include('Marketplace Preview Sandbox') + expect(container.description).to include('Nothing here is real.') + expect(container.description).to include('cleared automatically') + end end end end diff --git a/spec/services/course/assessment/marketplace/preview_launch_service_spec.rb b/spec/services/course/assessment/marketplace/preview_launch_service_spec.rb new file mode 100644 index 00000000000..2f53eaa662a --- /dev/null +++ b/spec/services/course/assessment/marketplace/preview_launch_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PreviewLaunchService, type: :service do + # The listing's own course, and the cross-tenant preview instance/container course, both live + # under the default tenant for this spec — same shape as PreviewContainerService's spec. + let!(:default_instance) { Instance.default } + + with_tenant(:default_instance) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + # Published through the real service rather than the `:versioned` factory trait: that trait parks + # its stand-in snapshot in the origin's own course, while everything asserted here is about a + # snapshot that lives in — and is attempted from — the container. + let(:listing) do + Course::Assessment::Marketplace::PublishService.publish(source_assessment, source_course.creator) + end + let(:user) { create(:user) } + let(:preview_instance) { Course::Assessment::Marketplace::PreviewContainerService.preview_instance } + let(:course) { Course::Assessment::Marketplace::PreviewContainerService.container_course } + let(:snapshot) { listing.current_version.assessment } + + describe '.launch' do + # The point of the converged design: publishing already placed the snapshot in the container, so + # launching a preview copies nothing. Duplicating per preview is exactly what would let a + # preview drift from the copy an adopter's duplicate produces. + it 'duplicates nothing — it attempts the snapshot that publishing placed in the container' do + listing # publish before measuring, so the snapshot itself is not counted as the delta + + expect { described_class.launch(listing, user) }. + not_to(change { course.assessments.count }) + end + + it 'returns the absolute attempt URL for the snapshot on the preview host' do + url = described_class.launch(listing, user) + + expect(url).to start_with('https://preview.') + expect(url).to include("/courses/#{course.id}/") + expect(url).to include("/assessments/#{snapshot.id}/") + expect(url).to end_with('/attempt') + end + + it 'attempts the container snapshot, never the authoring copy' do + url = described_class.launch(listing, user) + + expect(snapshot.course).to eq(course) + expect(snapshot.id).not_to eq(listing.authoring_assessment_id) + expect(url).not_to include("/assessments/#{listing.authoring_assessment_id}/") + end + + it 'enrols the previewer as a manager on first launch' do + # Force the listing (and its own source course) into existence before the assertion block: + # creating a course also creates an `owner` CourseUser for its creator, which would otherwise + # inflate this delta since CourseUser.count is global, not scoped to the container course. + listing + user + + expect { described_class.launch(listing, user) }. + to change { CourseUser.count }.by(1) + + course_user = course.course_users.find_by(user: user) + expect(course_user).to be_manager + expect(ActsAsTenant.with_tenant(preview_instance) { InstanceUser.exists?(user: user) }).to be true + end + + it 'does not re-enrol the previewer on re-launch' do + described_class.launch(listing, user) + + expect { described_class.launch(listing, user) }.not_to(change { CourseUser.count }) + end + + it 'returns the same attempt URL on re-launch' do + first = described_class.launch(listing, user) + + expect(described_class.launch(listing, user)).to eq(first) + end + end + end +end From e572d95e7fd1493402053b6236db43bdefebfe10 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:34:10 +0800 Subject: [PATCH 14/28] feat(marketplace): suppress real-course side effects in preview courses A rehearsal in the sandbox must not reach the outside world. Guarded at the three writers rather than their call sites, so a fourth cannot reintroduce it: - no graded-submission email (the previewer grades and publishes their own work, so it would land in their own inbox for a fake grade), - no task completion pushed to the previewer's real external LMS, - no `pending_staff_reply` flag, which in a container with no staff only ever surfaces as a nag on the Pending tab and the Comments badge. --- .../cikgo_task_completion_concern.rb | 3 ++ .../submission/workflow_event_concern.rb | 6 ++++ app/models/course/discussion/topic.rb | 13 +++++++- .../course/assessment/submission_spec.rb | 17 ++++++++++ spec/models/course/discussion/topic_spec.rb | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb b/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb index 334356eed1b..90b1cf85933 100644 --- a/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb +++ b/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb @@ -25,6 +25,9 @@ def publish_task_completion end def should_publish_task_completion? + # A sandbox rehearsal must never report a completed task to the previewer's real external LMS. + return false if lesson_plan_item.course.preview? + lesson_plan_item.course.component_enabled?(Course::StoriesComponent) && creator_id_on_cikgo.present? && status.present? end diff --git a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb index 51741c682ad..33730f43afc 100644 --- a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb +++ b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb @@ -184,6 +184,12 @@ def delete_attempting_current_answers end def send_email_after_publishing(send_email) + # Marketplace preview rehearsals happen in a `preview` sandbox course; the previewer grades and + # publishes their own submission, so this email would land in their own inbox for a fake grade. + # Guarded here rather than at the call sites because `publish!`'s `send_email` is positional and the + # UpdateService path reaches it via `alias_method :publish=, :publish!`, which cannot pass a 2nd arg. + return if assessment.course.preview? + return unless send_email && persisted? && !assessment.autograded? && submission_graded_email_enabled? && submission_graded_email_subscribed? diff --git a/app/models/course/discussion/topic.rb b/app/models/course/discussion/topic.rb index 32a0fa0fefc..5eab4ddacb2 100644 --- a/app/models/course/discussion/topic.rb +++ b/app/models/course/discussion/topic.rb @@ -81,8 +81,19 @@ def ensure_subscribed_by(user) raise e end + # No-op in the marketplace preview sandbox. Every topic there hangs off a throwaway rehearsal + # attempt that a TTL reaps within a day, and the container has no staff who owe anyone a reply — so + # the flag only ever surfaces as a nag, since both the "Pending" tab and the Comments sidebar badge + # count `pending_staff_reply` (Course::Discussion::TopicsHelper#all_staff_unread_count). Guarded + # here rather than at the four writers (the inbox toggle, a non-staff reply, + # Course::Assessment::Answer::AiGeneratedPostService's draft, and Codaveri async feedback) so a + # fifth cannot reintroduce it. Same course-wide suppression of preview side effects as + # Course::Assessment::Submission::WorkflowEventConcern and CikgoTaskCompletionConcern. + # + # Returns true, not false: the writers treat a falsey result as a failure and roll the enclosing + # post creation back (Course::Discussion::PostsConcern#update_topic_pending_status). def mark_as_pending - return true if pending_staff_reply + return true if course.preview? || pending_staff_reply self.pending_staff_reply = true save diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index 7da9fe68604..0c7277408e1 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -435,6 +435,23 @@ def set_assessment_email_setting(course, category_id, setting, regular, phantom) expect { submission.publish! }.to change { ActionMailer::Base.deliveries.count }.by(1) end + context 'when the submission belongs to a marketplace preview rehearsal', type: :mailer do + let(:course) { create(:course, preview: true) } + let(:course_student1) { create(:course_manager, course: course) } + + it 'does not send the graded email' do + expect { submission.publish! }.not_to(change { ActionMailer::Base.deliveries.count }) + end + + context 'when the course is not a preview sandbox' do + let(:course) { create(:course) } + + it 'still sends the graded email' do + expect { submission.publish! }.to change { ActionMailer::Base.deliveries.count }.by(1) + end + end + end + context 'when a user unsubscribes', type: :mailer do before do setting_email = course. diff --git a/spec/models/course/discussion/topic_spec.rb b/spec/models/course/discussion/topic_spec.rb index ec1003272a2..50a57bba4f2 100644 --- a/spec/models/course/discussion/topic_spec.rb +++ b/spec/models/course/discussion/topic_spec.rb @@ -53,6 +53,39 @@ end end + describe '#mark_as_pending' do + let(:course) { create(:course) } + # The actable is built with the same course on purpose: `:course_discussion_topic`'s default + # actable is a forum topic that brings its OWN course, and Course::Forum::Topic#set_course then + # overwrites `course_id` with the forum's — so a bare `course:` here is silently discarded and + # the topic ends up in a different, non-preview course. + let(:discussion_topic) do + create(:course_discussion_topic, course: course, actable: build(:forum_topic, course: course)) + end + + it 'marks the topic as pending staff reply' do + expect(discussion_topic.mark_as_pending).to be_truthy + expect(discussion_topic.reload.pending_staff_reply).to eq(true) + end + + context 'when the topic is in the marketplace preview sandbox' do + let(:course) { create(:course, preview: true) } + + it 'leaves the topic unmarked' do + discussion_topic.mark_as_pending + + expect(discussion_topic.reload.pending_staff_reply).to eq(false) + end + + # The writers treat a falsey return as a failure worth rolling the whole post creation back + # (see Course::Discussion::PostsConcern#update_topic_pending_status), so suppression must + # report success. + it 'still reports success' do + expect(discussion_topic.mark_as_pending).to be_truthy + end + end + end + describe '#ensure_subscribed_by' do context 'when the user has subscribed to a topic' do let!(:discussion_topic_subscription) do From f38ac40c3eeadbf0d447b1d8d7402e22345c2009 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:34:45 +0800 Subject: [PATCH 15/28] feat(assessment): serve breadcrumbs from a payload of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both crumb handles on any assessment page fetch `show`, and so does the page itself. One endpoint serving both means a breadcrumb costs the page's ~50 queries to render two strings — and made the marketplace sandbox's crumb allowance a licence to read the whole authoring surface. `show` now renders `crumb` for a `?crumb=true` request, skipping the two `before_action`s the full payload needs. Split on the request rather than on the viewer, so the payload stays the same for everyone. --- .../assessment/assessments_controller.rb | 16 +++++++++-- .../assessments/crumb.json.jbuilder | 5 ++++ .../app/api/course/Assessment/Assessments.js | 13 +++++++++ .../app/bundles/course/assessment/handles.ts | 10 +++++-- .../assessment/operations/assessments.ts | 8 ++++++ .../types/course/assessment/assessments.ts | 12 ++++++++ .../assessment/assessments_controller_spec.rb | 28 +++++++++++++++++++ 7 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 app/views/course/assessment/assessments/crumb.json.jbuilder diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index b8d21d9e6d8..dac7d391c7f 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -6,14 +6,14 @@ class Course::Assessment::AssessmentsController < Course::Assessment::Controller include Course::Assessment::Question::KoditsuQuestionConcern include Course::Assessment::KoditsuAssessmentInvitationConcern - before_action :load_submissions, only: [:show] + before_action :load_submissions, only: [:show], unless: :crumb_request? after_action :create_koditsu_invitation_job, only: [:update] after_action :create_fetch_koditsu_submissions_job, only: [:update] include Course::Assessment::MonitoringConcern include Course::Statistics::CountsConcern - before_action :load_question_duplication_data, only: [:show, :reorder] + before_action :load_question_duplication_data, only: [:show, :reorder], unless: :crumb_request? def index @assessments = @assessments.ordered_by_date_and_title.with_submissions_by(current_user) @@ -37,6 +37,7 @@ def index def show @assessment_time = @assessment.time_for(current_course_user) return render 'authenticate' unless can_access_assessment? + return render 'crumb' if crumb_request? @question_assessments = @assessment.question_assessments.with_question_actables @assessment_conditions = @assessment.assessment_conditions.includes({ conditional: :actable }) @@ -263,6 +264,17 @@ def load_assessment_options private + # Whether this `show` is asking only for what a breadcrumb renders — the assessment's title and its + # tab's. Both crumb handles on any assessment page fetch `show`, and so does the assessment page + # itself; one endpoint serving both is what made the marketplace sandbox's crumb allowance a licence + # to read the authoring surface. Splitting them on the request rather than on the viewer keeps the + # payload the same for everyone and saves the page's ~50 queries on a fetch that renders two strings. + # + # @return [Boolean] + def crumb_request? + action_name.to_sym == :show && params[:crumb].present? + end + # Drives the view-only version badge on the container course's assessment index. Every published # snapshot keeps its original title and shares one tab, so without it an admin sees an # undifferentiated pile of identically-named assessments. diff --git a/app/views/course/assessment/assessments/crumb.json.jbuilder b/app/views/course/assessment/assessments/crumb.json.jbuilder new file mode 100644 index 00000000000..a23a235dbc8 --- /dev/null +++ b/app/views/course/assessment/assessments/crumb.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Everything a breadcrumb renders, and nothing else. The same partial `authenticate` and +# `blocked_by_monitor` open with, so a crumb request reads the same four fields whichever of the three +# `show` renders. +json.partial! 'assessment_list_data', assessment: @assessment, category: @category, tab: @tab, course: current_course diff --git a/client/app/api/course/Assessment/Assessments.js b/client/app/api/course/Assessment/Assessments.js index 8091b8737f6..825ca8039bb 100644 --- a/client/app/api/course/Assessment/Assessments.js +++ b/client/app/api/course/Assessment/Assessments.js @@ -22,6 +22,19 @@ export default class AssessmentsAPI extends BaseCourseAPI { return this.client.get(`${this.#urlPrefix}/${assessmentId}`); } + /** + * Fetches only what a breadcrumb renders for an assessment: its title and its tab's. The full + * `fetch` is the assessment page's payload, and serving both from one request is what let a + * marketplace previewer read the authoring surface through the allowance their crumbs need. + * @param {number} assessmentId + * @returns An `AssessmentCrumbData` object + */ + fetchCrumb(assessmentId) { + return this.client.get(`${this.#urlPrefix}/${assessmentId}`, { + params: { crumb: true }, + }); + } + /** * Fetches the remaining unlock requirements for an assessment. * @param {number} assessmentId diff --git a/client/app/bundles/course/assessment/handles.ts b/client/app/bundles/course/assessment/handles.ts index e19b8386ed9..272e51417c2 100644 --- a/client/app/bundles/course/assessment/handles.ts +++ b/client/app/bundles/course/assessment/handles.ts @@ -3,7 +3,11 @@ import { getIdFromUnknown } from 'utilities'; import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; -import { fetchAssessment, fetchAssessments } from './operations/assessments'; +import { + fetchAssessment, + fetchAssessmentCrumb, + fetchAssessments, +} from './operations/assessments'; const getTabTitle = async ( categoryId?: number, @@ -23,7 +27,7 @@ const getTabTitle = async ( const getTabTitleFromAssessmentId = async ( assessmentId: number, ): Promise => { - const data = await fetchAssessment(assessmentId); + const data = await fetchAssessmentCrumb(assessmentId); return { activePath: data.tabUrl.split('&tab')[0], @@ -62,7 +66,7 @@ export const assessmentHandle: DataHandle = (match) => { return { getData: async (): Promise => { - const data = await fetchAssessment(assessmentId); + const data = await fetchAssessmentCrumb(assessmentId); return data.title; }, }; diff --git a/client/app/bundles/course/assessment/operations/assessments.ts b/client/app/bundles/course/assessment/operations/assessments.ts index b962f0e1f22..79c3a3555bd 100644 --- a/client/app/bundles/course/assessment/operations/assessments.ts +++ b/client/app/bundles/course/assessment/operations/assessments.ts @@ -2,6 +2,7 @@ import { AxiosError } from 'axios'; import { Operation } from 'store'; import { + AssessmentCrumbData, AssessmentDeleteResult, AssessmentsListData, AssessmentUnlockRequirements, @@ -35,6 +36,13 @@ export const fetchAssessment = async ( return response.data; }; +export const fetchAssessmentCrumb = async ( + id: number, +): Promise => { + const response = await CourseAPI.assessment.assessments.fetchCrumb(id); + return response.data; +}; + export const fetchAssessmentUnlockRequirements = async ( id: number, ): Promise => { diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 669553a9522..2464d49a275 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -214,6 +214,18 @@ export interface AssessmentData extends AssessmentActionsData { generateQuestionUrls?: GenerateQuestionBuilderData[]; } +/** + * What `show` renders for a breadcrumb request, and the subset every one of the three full variants + * below opens with — so a crumb consumer reads the same fields whichever the backend falls through to + * (view-password locked, monitor-blocked, or accessible). + */ +export interface AssessmentCrumbData { + id: number; + title: string; + tabTitle: string; + tabUrl: string; +} + export interface UnauthenticatedAssessmentData { id: number; title: string; diff --git a/spec/controllers/course/assessment/assessments_controller_spec.rb b/spec/controllers/course/assessment/assessments_controller_spec.rb index 10129988692..11bcd998241 100644 --- a/spec/controllers/course/assessment/assessments_controller_spec.rb +++ b/spec/controllers/course/assessment/assessments_controller_spec.rb @@ -133,6 +133,34 @@ end end + describe '#show' do + render_views + + let(:assessment) { create(:assessment, :published_with_all_question_types, course: course) } + + # A breadcrumb needs a title and a tab; the assessment page needs everything. They shared one + # endpoint, which is what let a marketplace previewer reach the authoring surface through the + # allowance their breadcrumb needs (see ApplicationPreviewSandboxConcern). The flag splits them. + context 'when the request asks only for breadcrumb data' do + before { get :show, as: :json, params: { course_id: course, id: assessment, crumb: true } } + + it 'renders the title and the tab, and nothing else' do + expect(response.parsed_body.keys).to contain_exactly('id', 'title', 'tabTitle', 'tabUrl') + end + + it 'does not assemble the assessment page' do + expect(assigns(:submissions)).to be_nil + expect(assigns(:question_duplication_dropdown_data)).to be_nil + expect(assigns(:questions)).to be_nil + end + end + + it 'renders the whole assessment page without the flag' do + get :show, as: :json, params: { course_id: course, id: assessment } + expect(response.parsed_body['permissions']).to be_present + end + end + describe '#destroy' do subject { delete :destroy, params: { course_id: course, id: immutable_assessment } } From 926a428a8e4130c48f6d699f24c5d098cfb5e2f8 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:35:09 +0800 Subject: [PATCH 16/28] feat(marketplace): confine previewers to the preview flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A previewer is enrolled as a `manager` of the shared container course — the lowest role that can attempt, grade and publish. On the ordinary abilities that is also a licence to read and edit the whole course, and a subtractive allow-list can never be complete: it has to name every component, every custom verb and every collection action, and each new one arrives switched on. `ApplicationPreviewSandboxConcern` inverts it. On the preview instance a non-administrator reaches only what a controller explicitly claims via `preview_sandbox_accessible?`, mirroring how `publicly_accessible?` marks out the unauthenticated surface. A component added tomorrow is denied without anyone remembering to deny it. Alongside it, `restrict_preview_course_reads` revokes the manager grants that would otherwise reach another previewer's submission, the aggregate gradebook and the roster of everyone who has ever previewed here. The lock is per-VIEWER: a system administrator curates the container from inside it, so the sandbox stays navigable for them. --- app/controllers/announcements_controller.rb | 6 + app/controllers/application_controller.rb | 3 + .../attachment_references_controller.rb | 8 + .../application_preview_sandbox_concern.rb | 87 +++++++++ .../assessment/assessments_controller.rb | 9 + .../submission/answer/controller.rb | 12 ++ .../submission/submissions_controller.rb | 27 +++ app/controllers/course/courses_controller.rb | 10 + .../course/user_notifications_controller.rb | 6 + app/controllers/csrf_token_controller.rb | 5 + app/controllers/jobs_controller.rb | 5 + ...ssessment_marketplace_ability_component.rb | 39 +++- app/views/application/index.json.jbuilder | 4 + .../application_root_payload_spec.rb | 70 +++++++ .../marketplace/preview_sandbox_lock_spec.rb | 182 ++++++++++++++++++ .../submission/submissions_controller_spec.rb | 48 +++++ .../course/gradebook_controller_spec.rb | 37 ++++ .../course/users_controller_spec.rb | 33 ++++ .../assessment_marketplace_ability_spec.rb | 73 +++++++ 19 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 app/controllers/concerns/application_preview_sandbox_concern.rb create mode 100644 spec/controllers/application_root_payload_spec.rb create mode 100644 spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb diff --git a/app/controllers/announcements_controller.rb b/app/controllers/announcements_controller.rb index d583f83abbe..6db79b7666a 100644 --- a/app/controllers/announcements_controller.rb +++ b/app/controllers/announcements_controller.rb @@ -26,6 +26,12 @@ def publicly_accessible? requesting_unread? || action_name.to_sym == :mark_as_read end + # The announcement bell polls this on every page. There are no global announcements on the preview + # instance, but a 403 on every poll would surface to the previewer as a stream of errors. + def preview_sandbox_accessible? + true + end + private def requesting_unread? diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c12193711ad..91f74c60463 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,6 +14,9 @@ class ApplicationController < ActionController::Base include ApplicationAbilityConcern include ApplicationAnnouncementsConcern include ApplicationPaginationConcern + # Last, so its `before_action` runs after the tenant is deduced and the user authenticated — both of + # which it reads. + include ApplicationPreviewSandboxConcern rescue_from AuthenticationError, with: :handle_authentication_error rescue_from IllegalStateError, with: :handle_illegal_state_error diff --git a/app/controllers/attachment_references_controller.rb b/app/controllers/attachment_references_controller.rb index e2f2bd95be0..f1f155af148 100644 --- a/app/controllers/attachment_references_controller.rb +++ b/app/controllers/attachment_references_controller.rb @@ -25,6 +25,14 @@ def show end end + protected + + # A previewed assessment's questions carry attachments (description images, template files) and its + # answers accept uploads, so both reading and writing an attachment are part of attempting one. + def preview_sandbox_accessible? + true + end + private def file_params diff --git a/app/controllers/concerns/application_preview_sandbox_concern.rb b/app/controllers/concerns/application_preview_sandbox_concern.rb new file mode 100644 index 00000000000..322bc3ab787 --- /dev/null +++ b/app/controllers/concerns/application_preview_sandbox_concern.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +# Confines a marketplace previewer to the preview flow, at the request layer. +# +# `Course::Assessment::Marketplace::PreviewLaunchService` enrols a previewer as a `manager` of the one +# container course — the lowest role that can attempt, grade and publish, which the submission +# machinery needs. That role is also, on the ordinary abilities, a licence to read and edit the whole +# course. `Course::AssessmentMarketplaceAbilityComponent` subtracts from it verb by verb, and the +# de-linked sidebar and breadcrumbs hide what is left; neither stops a typed URL, and a subtractive +# list can never be complete — it has to name every component, every custom verb and every collection +# action, and each new one arrives switched on. +# +# So this inverts it: on the preview instance a non-administrator may reach only what a controller +# explicitly claims. `preview_sandbox_accessible?` defaults to false and is overridden by the handful +# of actions the preview flow actually makes, mirroring how `publicly_accessible?` marks out the +# unauthenticated surface. A component added tomorrow is denied here without anyone remembering to +# deny it. +# +# The lock is per-VIEWER, not per-course: a system administrator curates the container from inside it +# — its version snapshots, and the authoring copies `RestoreAuthoringJob` rebuilds there — so the +# sandbox stays fully navigable for them. Mirrors the `!user&.administrator?` guard that gates the +# content freeze in `Course::AssessmentMarketplaceAbilityComponent#define_permissions`. +# +# `CanCan::AccessDenied` rather than a bespoke error: `ApplicationUserConcern` already rescues it into +# a JSON 403, and the frontend already knows what that means. +module ApplicationPreviewSandboxConcern + extend ActiveSupport::Concern + + included do + before_action :enforce_preview_sandbox_lock! + + # The root payload reports the lock to the courseless navigation shell, which has no course to read + # a flag off — the 404 page drops its "go back home" link on it. One fact, one source: the same + # predicate this concern enforces, rather than a second guess at who is confined. + helper_method :preview_sandbox_locked? + end + + protected + + # Whether this action belongs to the marketplace preview flow, and may therefore run for a + # non-administrator on the preview instance. Deny by default; override in the controllers that serve + # the flow. + # + # Devise is exempt wholesale rather than by action: sign-in, sign-up, password reset and + # confirmation all happen on the preview host, so a previewer who arrives without a session must be + # able to complete them or the sandbox is unreachable. Exempting the base class cannot miss one of + # the four subclasses. + # + # The root payload (locale, time zone, and the courses the user is in — here, only the container) is + # fetched on every page, the previewer's included. Singled out the same way `publicly_accessible?` + # singles out that one action. + # + # @return [Boolean] + def preview_sandbox_accessible? + devise_controller? || (controller_name == 'application' && action_name.to_sym == :index) + end + + # Whether `assessment_id` names an assessment a previewer was actually handed: the snapshot a listed + # listing currently serves. Every other assessment in the container — superseded snapshots, restored + # authoring working copies, snapshots of delisted listings — is content nobody was given a URL to, + # and ids there run consecutively, so hiding the index is not on its own a boundary. + # + # Deliberately no `can?` call. This runs before `load_and_authorize_resource :course`, and + # `Course::Controller#current_ability` memoizes on `current_course`; building the ability here would + # freeze a nil-course one for the rest of the request and deny the previewer everything downstream. + # The marketplace allow-list is enforced where the preview is launched, not on every page of it. + # + # @param [Integer, String, nil] assessment_id + # @return [Boolean] + def previewable_assessment?(assessment_id) + Course::Assessment::Marketplace::Listing.serving_assessment?(assessment_id) + end + + private + + def enforce_preview_sandbox_lock! + return unless preview_sandbox_locked? + return if preview_sandbox_accessible? + + raise CanCan::AccessDenied + end + + def preview_sandbox_locked? + return false if current_user&.administrator? + + Course::Assessment::Marketplace::PreviewContainerService.preview_instance?(current_tenant) + end +end diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index dac7d391c7f..f2ab34fd877 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -256,6 +256,15 @@ def plagiarism protected + # Both breadcrumb handles on a preview submission page fetch `show`, so the previewer needs it — + # but only for a title, and only for the snapshot they were handed. Not the page: a previewer is a + # `manager`, and `show` serves a manager the whole authoring surface. The index is not here either: + # it is the whole container, one row per published snapshot and per restored authoring copy, each + # with an Attempt button. + def preview_sandbox_accessible? + crumb_request? && previewable_assessment?(params[:id]) + end + def load_assessment_options return super if skip_tab_filter? diff --git a/app/controllers/course/assessment/submission/answer/controller.rb b/app/controllers/course/assessment/submission/answer/controller.rb index 322da753c3b..d05dc1b34cb 100644 --- a/app/controllers/course/assessment/submission/answer/controller.rb +++ b/app/controllers/course/assessment/submission/answer/controller.rb @@ -6,4 +6,16 @@ class Course::Assessment::Submission::Answer::Controller < \ singleton: true, through: :answer helper Course::Assessment::Submission::SubmissionsHelper.name.sub(/Helper$/, '') + + protected + + # Answering is the whole point of a preview, and every action in this subtree — saving an answer, + # uploading a text-response file, adding a scribble, annotating code — is reached through + # `load_and_authorize_resource :submission`, which the preview ability confines to + # `creator_id: user.id`. Claimed once on the base rather than per subclass, so a new answer type + # does not silently break previews. Declared here and not on + # `Course::Assessment::Submission::Controller`, which would also hand a previewer the access logs. + def preview_sandbox_accessible? + true + end end diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index bdae979fcc2..163dc0149b9 100644 --- a/app/controllers/course/assessment/submission/submissions_controller.rb +++ b/app/controllers/course/assessment/submission/submissions_controller.rb @@ -9,6 +9,21 @@ class Course::Assessment::Submission::SubmissionsController < # rubocop:disable include Course::Assessment::LiveFeedback::ThreadConcern include Course::Assessment::LiveFeedback::MessageConcern + # What a marketplace previewer may do here (see ApplicationPreviewSandboxConcern). Every one of + # these rides on `load_and_authorize_resource :submission` or an explicit `authorize!` against + # `@submission`, which the preview ability confines to `creator_id: user.id` — so allowing the action + # does not widen which submission it can reach. `create` is the exception and vets the assessment + # itself. + # + # Deliberately absent: every collection action. `publish_all`, `force_submit_all`, `unsubmit_all`, + # `download_all` and friends authorize against `@assessment` on verbs a manager's blanket + # `can :manage, Course::Assessment` already satisfies, so in a course every previewer shares they + # would reach every other previewer's submission. The live-feedback actions are absent too: + # `fetch_live_feedback_chat` reads a thread from a bare `answer_id` with no authorization at all. + PREVIEWER_ACTIONS = [ + :create, :edit, :update, :auto_grade, :reevaluate_answer, :generate_feedback, :reload_answer + ].to_set.freeze + before_action :authorize_assessment!, only: :create skip_authorize_resource :submission, only: [:edit, :update, :auto_grade] before_action :authorize_submission!, only: [:edit, :update] @@ -325,6 +340,18 @@ def delete_all render partial: 'jobs/submitted', locals: { job: job } end + protected + + def preview_sandbox_accessible? + return false unless PREVIEWER_ACTIONS.include?(action_name.to_sym) + # `create` backs the `attempt` route and mints the submission every other action here is scoped + # to, so it is the one that has to vet the assessment. Without this, guessing a container + # assessment id would hand the guesser a submission on it and legitimise everything downstream. + return true unless action_name.to_sym == :create + + previewable_assessment?(params[:assessment_id]) + end + private # When a grader opens a (submitted) submission, make sure every rubric-based answer has a v2 grading diff --git a/app/controllers/course/courses_controller.rb b/app/controllers/course/courses_controller.rb index 0719097bd80..7460520bb31 100644 --- a/app/controllers/course/courses_controller.rb +++ b/app/controllers/course/courses_controller.rb @@ -53,6 +53,16 @@ def publicly_accessible? Set[:index, :show, :sidebar].include?(action_name.to_sym) end + # The layout payload is fetched on every course page, so the previewer's submission page needs it. + # + # `show` is deliberately NOT here. Its payload lists `current_course.managers`, which in the + # container is every previewer in the instance, and the sandbox explainer it renders now has no + # previewer audience: `PreviewLaunchService` lands them on the submission, `PreviewCourseBanner` + # says the same thing there, and the de-linked sidebar never offers the home page. + def preview_sandbox_accessible? + action_name.to_sym == :sidebar + end + private def course_params diff --git a/app/controllers/course/user_notifications_controller.rb b/app/controllers/course/user_notifications_controller.rb index de5b435259b..db8702def03 100644 --- a/app/controllers/course/user_notifications_controller.rb +++ b/app/controllers/course/user_notifications_controller.rb @@ -18,6 +18,12 @@ def publicly_accessible? Set[:fetch].include?(action_name.to_sym) end + # `PopupNotifier` polls `fetch` on every course page and dismisses through `mark_as_read`; both are + # scoped to the previewer's own notifications, `mark_as_read` by `load_and_authorize_resource`. + def preview_sandbox_accessible? + Set[:fetch, :mark_as_read].include?(action_name.to_sym) + end + private # Fetches the first unread popup `UserNotification` for the current course and returns JSON data diff --git a/app/controllers/csrf_token_controller.rb b/app/controllers/csrf_token_controller.rb index 58026dd4ba6..0d0d04edfb7 100644 --- a/app/controllers/csrf_token_controller.rb +++ b/app/controllers/csrf_token_controller.rb @@ -9,4 +9,9 @@ def csrf_token def publicly_accessible? true end + + # Every mutating request the previewer makes needs a token first. + def preview_sandbox_accessible? + true + end end diff --git a/app/controllers/jobs_controller.rb b/app/controllers/jobs_controller.rb index 6185ab2c61d..0edda8ee6bd 100644 --- a/app/controllers/jobs_controller.rb +++ b/app/controllers/jobs_controller.rb @@ -18,6 +18,11 @@ def publicly_accessible? true end + # Autograding a preview submission is a job, and the submission page polls it here. + def preview_sandbox_accessible? + true + end + private def load_job diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index 70f83276b34..3bdb5839513 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -34,7 +34,15 @@ def define_non_admin_course_permissions # super chain, so a `cannot` here takes precedence. This line is load-bearing. cannot :access_marketplace, Course, id: course.id end - restrict_preview_course_content if course.preview? + return unless course.preview? + + # Order matters: restrict_preview_course_reads's broad `:manage` cannot/can pair on + # Course::Assessment::Submission is defined BEFORE restrict_preview_course_content's narrower, + # verb-specific `:delete_submission`/`:reset_own_preview_submission` rules, so the latter — being + # defined LATER — keeps final precedence over those two specific verbs (CanCan evaluates rules in + # reverse-definition order: last defined wins). + restrict_preview_course_reads + restrict_preview_course_content end # Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns @@ -71,15 +79,42 @@ def allow_admins_publish_to_marketplace # and Course::CourseAbilityComponent in the `define_permissions` super chain: AbilityHost.components # is ordered by file path, and `_` (0x5F) sorts before `s` (0x73). Do not rename or move this file. def restrict_preview_course_content - assessments_in_course = { tab: { category: { course_id: course.id } } } cannot [:update, :destroy], Course::Assessment, assessments_in_course cannot :delete_all_submissions, Course::Assessment, assessments_in_course + # `:delete_submission` is revoked wholesale (not scoped to `creator_id`) because every + # previewer shares this one container course as a `manager`, and a manager's blanket + # `allow_manager_delete_assessment_submissions` would otherwise let them delete ANY + # previewer's submission, not just their own. Self-service reset of one's OWN submission is + # therefore a distinct, narrowly-scoped verb below, rather than a `creator_id`-scoped carve-out + # of `:delete_submission` (which CanCan cannot express alongside a blanket `cannot` on the same + # action). cannot :delete_submission, Course::Assessment::Submission, assessment: assessments_in_course + can :reset_own_preview_submission, Course::Assessment::Submission, + creator_id: user.id, assessment: assessments_in_course PREVIEW_FROZEN_QUESTION_TYPES.each do |question_class| cannot [:create, :update, :destroy], question_class end end + def assessments_in_course + { tab: { category: { course_id: course.id } } } + end + + # In a `preview` sandbox course, previewers are enrolled as `manager` of a container course SHARED + # by every other previewer in the whole instance (see PreviewContainerService). A manager's + # ordinary abilities would let them read/grade/publish ANY other previewer's submission, list every + # submission for any assessment in the sandbox, see the aggregate gradebook, and browse the full + # roster of everyone who has ever previewed anything here — none of which is any given previewer's + # business. Revoke it all, then carve back exactly their own submission. + def restrict_preview_course_reads + cannot :manage, Course::Assessment::Submission, assessment: assessments_in_course + can :manage, Course::Assessment::Submission, creator_id: user.id, assessment: assessments_in_course + cannot :view_all_submissions, Course::Assessment, assessments_in_course + cannot :read_gradebook, Course, id: course.id + cannot [:show_users, :manage_users], Course, id: course.id + cannot :manage, CourseUser + end + def allow_managers_access_marketplace can :access_marketplace, Course, id: course.id # Subject is the listing, not the assessment (design V13). Resolving it from an assessment would diff --git a/app/views/application/index.json.jbuilder b/app/views/application/index.json.jbuilder index 710bd18e660..cd434c71445 100644 --- a/app/views/application/index.json.jbuilder +++ b/app/views/application/index.json.jbuilder @@ -1,6 +1,10 @@ # frozen_string_literal: true json.locale I18n.locale json.timeZone ActiveSupport::TimeZone::MAPPING[user_time_zone] +# The courseless counterpart to the same field in `course/courses/sidebar.json.jbuilder`, for the +# navigation shell that renders without a course. A string comparison against the loaded tenant, so it +# costs nothing on an endpoint every page hits. +json.isPreviewRestricted preview_sandbox_locked? if user_signed_in? my_courses = Course.containing_user(current_user).ordered_by_start_at diff --git a/spec/controllers/application_root_payload_spec.rb b/spec/controllers/application_root_payload_spec.rb new file mode 100644 index 00000000000..0de3c573e27 --- /dev/null +++ b/spec/controllers/application_root_payload_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +require 'rails_helper' + +# The root payload is fetched on every page and is the only data the courseless navigation shell has, +# so it is where the preview sandbox lock reaches the surfaces that cannot read it off a course — the +# 404 page, which drops its "go back home" link when the flag is set. +# +# A separate file from `application_controller_spec.rb`: that spec defines an anonymous +# `controller do ... end`, which cannot render the real `application/index.json.jbuilder`. +# +# Drives the REAL preview instance rather than a stand-in `create(:course, preview: true)`, the same +# way `spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb` does: the +# predicate keys off the instance precisely because a courseless page has no course to read a flag +# from, so a stand-in course elsewhere would not trip it. +RSpec.describe ApplicationController, 'root payload', type: :controller do + render_views + + subject(:payload) do + get :index, format: :json + JSON.parse(response.body) + end + + let(:preview_instance) { Course::Assessment::Marketplace::PreviewContainerService.preview_instance } + let(:previewer) { ActsAsTenant.with_tenant(Instance.default) { create(:user) } } + let(:listing) do + ActsAsTenant.with_tenant(Instance.default) do + source = create(:assessment, course: create(:course)) + Course::Assessment::Marketplace::PublishService.publish(source, source.course.creator) + end + end + + before { Course::Assessment::Marketplace::PreviewLaunchService.launch(listing, previewer) } + + context 'when on the preview instance' do + with_tenant(:preview_instance) do + context 'when the viewer is a previewer' do + before { controller_sign_in(controller, previewer) } + + it 'reports the sandbox lock' do + expect(payload['isPreviewRestricted']).to be(true) + end + end + + # The lock is per-viewer: a system administrator curates the container from inside it, so the + # navigation shell stays whole for them. Mirrors the exemption in `preview_sandbox_locked?`. + context 'when the viewer is a system administrator' do + let(:administrator) { ActsAsTenant.with_tenant(Instance.default) { create(:administrator) } } + + before { controller_sign_in(controller, administrator) } + + it 'does not report the sandbox lock' do + expect(payload['isPreviewRestricted']).to be(false) + end + end + end + end + + # Same previewer, ordinary instance: the lock is a property of where they are, not of who they are. + context 'when outside the preview instance' do + let(:instance) { Instance.default } + + with_tenant(:instance) do + before { controller_sign_in(controller, previewer) } + + it 'does not report the sandbox lock' do + expect(payload['isPreviewRestricted']).to be(false) + end + end + end +end diff --git a/spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb b/spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb new file mode 100644 index 00000000000..0fc2e2742cb --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true +require 'rails_helper' + +# The lock is one request-layer allow-list spanning many controllers, so it is specced in one file +# rather than smeared across each controller's own spec. What matters is the boundary itself: reading +# the allowed and denied surfaces side by side is the only way to see what a previewer can still +# reach, and a per-controller spec cannot say "and nothing else". +# +# This spec drives the REAL preview instance and container course, unlike the rest of the marketplace +# suite, which stands in a throwaway `create(:course, preview: true)`. It has to: the lock keys off +# the instance (a previewer must be confined on courseless pages too, where there is no course to +# read a flag from), so a stand-in course on some other instance would not trip it. Same call the +# production path makes, so the setup is `PublishService` + `PreviewLaunchService` rather than +# hand-built fixtures. +RSpec.shared_context 'marketplace preview sandbox' do + let(:preview_instance) { Course::Assessment::Marketplace::PreviewContainerService.preview_instance } + let(:container) do + ActsAsTenant.without_tenant { Course::Assessment::Marketplace::PreviewContainerService.container_course } + end + let(:previewer) { ActsAsTenant.with_tenant(Instance.default) { create(:user) } } + + # Published from an ordinary course on the default instance, exactly as a real listing is: the + # snapshot `PublishService` puts in the container is what a previewer is handed. + let(:listing) do + ActsAsTenant.with_tenant(Instance.default) do + source = create(:assessment, course: create(:course)) + Course::Assessment::Marketplace::PublishService.publish(source, source.course.creator) + end + end + let(:snapshot) { ActsAsTenant.without_tenant { listing.current_version.assessment } } + + # A container assessment no published listing serves — a superseded snapshot, a restored authoring + # working copy, or the snapshot of a delisted listing all look like this. Container ids are + # guessable, so reaching one must not depend on the index being hidden. + let(:unserved_assessment) do + ActsAsTenant.with_tenant(preview_instance) { create(:assessment, course: container) } + end + + before { Course::Assessment::Marketplace::PreviewLaunchService.launch(listing, previewer) } +end + +RSpec.describe Course::CoursesController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + it 'denies the sandbox course home page' do + expect { get :show, params: { id: container, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + + it 'denies the instance course index' do + expect { get :index, format: :json }.to raise_exception(CanCan::AccessDenied) + end + + # The layout payload is fetched on every course page, so denying it would take the submission + # page down with it. + it 'allows the sidebar' do + get :sidebar, params: { id: container, format: :json } + expect(response).to have_http_status(:success) + end + end +end + +RSpec.describe Course::AnnouncementsController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + # Stands in for every component page in the sandbox: lesson plan, materials, forums, surveys, + # videos, comments, statistics. Announcements specifically, because it is one the ability + # component never mentions — a previewer's `manager` role carries it outright, so it fails without + # the lock. (The users page would pass either way: `cannot [:show_users, :manage_users]` already + # covers the roster, and asserting it here would prove nothing about this gate.) + it 'denies a component page nothing else revokes' do + expect { get :index, params: { course_id: container, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + end +end + +RSpec.describe Course::Assessment::AssessmentsController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + it 'denies the container assessment index' do + expect { get :index, params: { course_id: container, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + + # The assessment page is not part of the preview flow, and for a `manager` — which every previewer + # is — `show` serves the whole authoring surface: `canManage`, the question edit urls, the + # new-question and generate-question urls, graded test case visibility. Only the breadcrumb needs + # this endpoint, and only for a title. + it 'denies the snapshot page' do + expect { get :show, params: { course_id: container, id: snapshot, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + + # Both breadcrumb handles on the submission page fetch this endpoint, so it is load-bearing. + it 'allows a breadcrumb request for the snapshot a published listing serves' do + get :show, params: { course_id: container, id: snapshot, crumb: true, format: :json } + expect(response).to have_http_status(:success) + end + + it 'denies a breadcrumb request for a container assessment no published listing serves' do + expect do + get :show, params: { course_id: container, id: unserved_assessment, crumb: true, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end +end + +# The lock must fire on the preview instance and nowhere else — the manager role it denies there is +# an ordinary one everywhere else. Deliberately outside the shared context: nothing about this needs +# the container to exist. +RSpec.describe Course::Assessment::AssessmentsController, 'outside the preview instance', type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let!(:manager) { create(:course_manager, course: course) } + + before { controller_sign_in(controller, manager.user) } + + it 'leaves an ordinary course untouched' do + get :index, params: { course_id: course, format: :json } + expect(response).to have_http_status(:success) + end + end +end + +RSpec.describe Course::Assessment::Submission::SubmissionsController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + # `attempt` routes here. It mints the submission every later action is scoped to by `creator_id`, + # so it is the one action that has to vet the assessment rather than ride on the submission. + it 'allows attempting the snapshot a published listing serves' do + get :create, params: { course_id: container, assessment_id: snapshot, format: :json } + expect(response).to have_http_status(:success) + end + + it 'refuses to mint a submission on a container assessment no published listing serves' do + expect do + get :create, params: { course_id: container, assessment_id: unserved_assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + # A manager's blanket `can :manage, Course::Assessment` satisfies `:publish_grades`, and no + # `cannot` revokes it — this action would otherwise publish grades for every previewer's + # submission on the snapshot. + it 'denies publishing grades for the whole assessment' do + expect do + patch :publish_all, params: { course_id: container, assessment_id: snapshot, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end +end + +RSpec.describe Course::Assessment::AssessmentsController, 'preview sandbox administrators', type: :controller do + include_context 'marketplace preview sandbox' + + let(:administrator) { ActsAsTenant.with_tenant(Instance.default) { create(:administrator) } } + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, administrator) } + + # A system administrator curates the container from inside it, so the lock is per-viewer. Mirrors + # the exemption in Course::AssessmentMarketplaceAbilityComponent#define_permissions. + it 'is exempt from the lock' do + get :index, params: { course_id: container, format: :json } + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb index 5aa00a954e3..4b4901f9ce8 100644 --- a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb +++ b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb @@ -555,5 +555,53 @@ end end end + + context 'in the marketplace preview sandbox' do + let(:preview_course) { create(:course, preview: true) } + let(:preview_assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let(:previewer) { create(:user) } + let!(:previewer_course_user) { create(:course_manager, course: preview_course, user: previewer) } + let(:own_submission) do + create(:submission, :attempting, assessment: preview_assessment, + course: preview_course, creator: previewer) + end + + before { controller_sign_in(controller, previewer) } + + # Without Task 1's ability rules, a manager holds a course-wide (not creator-scoped) grant to + # read/update ANY submission and list all submissions, via + # Course::Assessment::AssessmentAbility#allow_staff_read_assessment_submissions / + # #allow_teaching_staff_grade_assessment_submissions (app/models/course/assessment/ + # assessment_ability.rb) — previewers get this too, since they're enrolled as `manager`. Task + # 1's restrict_preview_course_reads revokes it specifically inside a preview course. These + # examples assert the POST-Task-1 behavior; they are expected to fail before Task 1 lands (see + # Step 2) precisely because that broad grant is still in effect until then. + describe '#edit' do + it 'allows the previewer to edit their OWN submission' do + get :edit, params: { course_id: preview_course, assessment_id: preview_assessment, + id: own_submission, format: :json } + expect(response).to be_successful + end + + it "forbids the previewer from reading ANOTHER previewer's submission" do + other_previewer = create(:course_manager, course: preview_course).user + other_submission = create(:submission, :attempting, assessment: preview_assessment, + course: preview_course, creator: other_previewer) + expect do + get :edit, params: { course_id: preview_course, assessment_id: preview_assessment, + id: other_submission, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + describe '#index' do + it 'forbids listing all submissions in the sandbox' do + own_submission + expect do + get :index, params: { course_id: preview_course, assessment_id: preview_assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + end end end diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb index eb8ddf2446c..c83780cc973 100644 --- a/spec/controllers/course/gradebook_controller_spec.rb +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -853,5 +853,42 @@ def weight_for(tab) expect(external_titles).to eq(%w[Alpha Zeta]) end end + + context 'in the marketplace preview sandbox' do + let(:preview_course) { create(:course, preview: true) } + let(:previewer) { create(:course_manager, course: preview_course).user } + + before { controller_sign_in(controller, previewer) } + + it 'forbids reading the gradebook' do + expect do + get :index, params: { course_id: preview_course.id, format: :json } + end.to raise_error(CanCan::AccessDenied) + end + + it 'also forbids updating gradebook weights' do + # `authorize_read_gradebook!` is a `before_action` with no `only:` scoping + # (gradebook_controller.rb:5), so it gates #update_weights too, ahead of that + # action's own `authorize! :manage_gradebook_weights` check — read access is a + # prerequisite for write access on this controller. Task 1's `cannot + # :read_gradebook` therefore blocks this action as a side effect, which is + # consistent with (and reinforces) this plan's goal. + expect do + patch :update_weights, params: { course_id: preview_course.id, weights: [] }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + + context 'when a system administrator visits the preview sandbox gradebook' do + let(:preview_course) { create(:course, preview: true) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + it 'is not denied (admins are exempt from the preview restriction)' do + get :index, params: { course_id: preview_course.id }, format: :json + expect(response).to be_successful + end + end end end diff --git a/spec/controllers/course/users_controller_spec.rb b/spec/controllers/course/users_controller_spec.rb index 33acde7f24c..6e8f0e2d4f0 100644 --- a/spec/controllers/course/users_controller_spec.rb +++ b/spec/controllers/course/users_controller_spec.rb @@ -436,5 +436,38 @@ end end end + + context 'in the marketplace preview sandbox' do + let(:preview_course) { create(:course, preview: true) } + let(:previewer_course_user) { create(:course_manager, course: preview_course) } + let(:previewer) { previewer_course_user.user } + + before { controller_sign_in(controller, previewer) } + + it 'forbids listing the users roster' do + expect do + get :index, params: { course_id: preview_course.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + it "forbids reading another previewer's CourseUser record" do + other_course_user = create(:course_manager, course: preview_course) + expect do + get :show, params: { course_id: preview_course.id, id: other_course_user.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when a system administrator visits the preview sandbox users roster' do + let(:preview_course) { create(:course, preview: true) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + it 'is not denied (admins are exempt from the preview restriction)' do + get :index, params: { course_id: preview_course.id, format: :json } + expect(response).to be_successful + end + end end end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 8b81eedb78d..d16db943d3f 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -21,6 +21,7 @@ let(:user) { create(:administrator) } let(:course_user) { nil } it { is_expected.to be_able_to(:publish_to_marketplace, build(:assessment)) } + it { is_expected.to be_able_to(:access_marketplace, course) } end context 'when the user is a course manager' do @@ -151,6 +152,59 @@ it 'forbids deleting submissions in the sandbox' do expect(subject).not_to be_able_to(:delete_all_submissions, assessment) end + + it 'forbids the blanket delete_submission verb (would reach other previewers\' copies)' do + own_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: user) + expect(subject).not_to be_able_to(:delete_submission, own_submission) + end + + it 'permits the narrowly-scoped self-reset of their OWN submission' do + own_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: user) + expect(subject).to be_able_to(:reset_own_preview_submission, own_submission) + end + + it "forbids self-reset of ANOTHER previewer's submission" do + other_previewer = create(:course_manager, course: course).user + other_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: other_previewer) + expect(subject).not_to be_able_to(:reset_own_preview_submission, other_submission) + end + + it 'permits reading/updating their OWN submission via the broad grant' do + own_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: user) + expect(subject).to be_able_to(:read, own_submission) + expect(subject).to be_able_to(:update, own_submission) + end + + it "forbids reading/updating ANOTHER previewer's submission" do + other_previewer = create(:course_manager, course: course).user + other_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: other_previewer) + expect(subject).not_to be_able_to(:read, other_submission) + expect(subject).not_to be_able_to(:update, other_submission) + end + + it 'forbids listing all submissions for an assessment in the sandbox' do + expect(subject).not_to be_able_to(:view_all_submissions, assessment) + end + + it 'forbids reading the gradebook' do + expect(subject).not_to be_able_to(:read_gradebook, course) + end + + it 'forbids the show_users/manage_users roster views' do + expect(subject).not_to be_able_to(:show_users, course) + expect(subject).not_to be_able_to(:manage_users, course) + end + + it "forbids reading anyone's CourseUser record, including their own" do + expect(subject).not_to be_able_to(:show, course_user) + other_course_user = create(:course_manager, course: course) + expect(subject).not_to be_able_to(:show, other_course_user) + end end context 'and the user is a system administrator' do @@ -161,6 +215,13 @@ expect(subject).to be_able_to(:update, assessment) expect(subject).to be_able_to(:destroy, assessment) end + + it 'retains gradebook, users, and cross-submission access' do + expect(subject).to be_able_to(:read_gradebook, course) + other_submission = create(:submission, :attempting, assessment: assessment, course: course, + creator: create(:course_manager, course: course).user) + expect(subject).to be_able_to(:read, other_submission) + end end end @@ -174,6 +235,18 @@ expect(subject).to be_able_to(:update, assessment) expect(subject).to be_able_to(:destroy, assessment) end + + it 'never grants the preview-only self-reset verb outside a preview course' do + submission = create(:submission, :attempting, assessment: assessment, course: course, creator: user) + expect(subject).not_to be_able_to(:reset_own_preview_submission, submission) + end + + it 'retains gradebook, users, and cross-submission access (the restriction is preview-scoped)' do + expect(subject).to be_able_to(:read_gradebook, course) + other_submission = create(:submission, :attempting, assessment: assessment, course: course, + creator: create(:course_manager, course: course).user) + expect(subject).to be_able_to(:read, other_submission) + end end end end From 3dee33be2f81466ab26b145e114fd9f76f699b7f Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:35:25 +0800 Subject: [PATCH 17/28] feat(marketplace): de-link sandbox navigation for a restricted previewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox exists to rehearse the one assessment the previewer opened, and the lock denies everything else — so leaving live links around only offers them a 403. Sidebar items and breadcrumbs render as inert text instead. Home is withheld separately: it is the one sidebar entry that does not come from the `sidebar` payload, so the jbuilder's de-link never reached it. Gated on `isPreviewRestricted`, not `isPreview`: a system administrator curates the container from inside it and keeps working links. The same flag also keeps them off the sidebar's member identity, which they only hold there because launching a preview enrolled them. --- app/controllers/course/courses_controller.rb | 13 ++++ .../courses/_sidebar_items.json.jbuilder | 2 +- .../course/courses/sidebar.json.jbuilder | 10 ++- .../container/Breadcrumbs/Breadcrumbs.tsx | 8 +- .../Breadcrumbs/__test__/Breadcrumbs.test.tsx | 42 +++++++++++ .../course/container/Sidebar/Sidebar.tsx | 13 ++-- .../course/container/Sidebar/SidebarItem.tsx | 2 +- .../Sidebar/__test__/Sidebar.test.tsx | 61 +++++++++++++++ client/app/types/course/courses.ts | 4 + .../course/courses_controller_spec.rb | 75 +++++++++++++++++++ 10 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 client/app/bundles/course/container/Breadcrumbs/__test__/Breadcrumbs.test.tsx create mode 100644 client/app/bundles/course/container/Sidebar/__test__/Sidebar.test.tsx diff --git a/app/controllers/course/courses_controller.rb b/app/controllers/course/courses_controller.rb index 7460520bb31..f6f18e9bb30 100644 --- a/app/controllers/course/courses_controller.rb +++ b/app/controllers/course/courses_controller.rb @@ -45,6 +45,19 @@ def sidebar # # To re-enable, restore the original condition. @home_redirects_to_learn = false + + # The marketplace sandbox lock is per-viewer, not per-course. A system administrator curates the + # container from inside it — its version snapshots and the authoring copies RestoreAuthoringJob + # rebuilds there — so the sandbox must stay fully navigable for them, and the read-only banner + # would be a lie on pages they can edit. Mirrors the `!user&.administrator?` guard that gates the + # same freeze in Course::AssessmentMarketplaceAbilityComponent#define_permissions. + # + # An administrator's own CourseUser in the container is an artefact of having launched a preview + # (Course::Assessment::Marketplace::PreviewLaunchService enrols as `manager`, the lowest role that + # can attempt, grade and publish); it is not a role they were given, so the sidebar does not + # present them as a member of the sandbox. + @preview_sandbox_admin = current_course.preview? && current_user&.administrator? + @preview_restricted = current_course.preview? && !@preview_sandbox_admin end protected diff --git a/app/views/course/courses/_sidebar_items.json.jbuilder b/app/views/course/courses/_sidebar_items.json.jbuilder index fddd58385f9..7c4cfbd33d1 100644 --- a/app/views/course/courses/_sidebar_items.json.jbuilder +++ b/app/views/course/courses/_sidebar_items.json.jbuilder @@ -3,7 +3,7 @@ json.array! items do |item| json.key item[:key] json.label item[:title] json.icon item[:icon] - if can_read + if link_items json.path item[:path] json.unread item[:unread] if item[:unread]&.nonzero? end diff --git a/app/views/course/courses/sidebar.json.jbuilder b/app/views/course/courses/sidebar.json.jbuilder index 1a31ba3f24f..be7d54a19c8 100644 --- a/app/views/course/courses/sidebar.json.jbuilder +++ b/app/views/course/courses/sidebar.json.jbuilder @@ -6,7 +6,7 @@ json.courseUserUrl url_to_user_or_course_user(current_course, current_course_use json.userName current_user&.name json.userId current_user&.id -if current_course_user.present? && can?(:read, current_course) +if current_course_user.present? && !@preview_sandbox_admin && can?(:read, current_course) json.courseUserName current_course_user.name json.courseUserRole current_course_user.role json.userAvatarUrl user_image(current_course_user.user) @@ -25,14 +25,16 @@ if current_course_user.present? && can?(:read, current_course) end json.isCourseEnrollable current_course.enrollable? +json.isPreview current_course.preview +json.isPreviewRestricted @preview_restricted -can_read = can?(:read, current_course) +link_items = can?(:read, current_course) && !@preview_restricted json.sidebar do - json.partial! 'sidebar_items', items: controller.sidebar_items(type: :normal), can_read: can_read + json.partial! 'sidebar_items', items: controller.sidebar_items(type: :normal), link_items: link_items end unless (admin_sidebar_items = controller.sidebar_items(type: :admin)).empty? json.adminSidebar do - json.partial! 'sidebar_items', items: admin_sidebar_items, can_read: can_read + json.partial! 'sidebar_items', items: admin_sidebar_items, link_items: link_items end end diff --git a/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx index 7527e956bdf..ef9fc8cd363 100644 --- a/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx +++ b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx @@ -12,10 +12,12 @@ interface BreadcrumbProps { in: CrumbData[]; className?: string; loading?: boolean; + /** Renders every crumb as inert text instead of a link — e.g. inside the marketplace preview sandbox, where nothing besides the current submission should be reachable. */ + disableLinks?: boolean; } const Breadcrumbs = (props: BreadcrumbProps): JSX.Element => { - const { in: crumbs } = props; + const { in: crumbs, disableLinks } = props; const { t } = useTranslation(); @@ -26,14 +28,14 @@ const Breadcrumbs = (props: BreadcrumbProps): JSX.Element => { forEachFlatCrumb(crumbs, (content, isLastCrumb, key) => { elements.push( - + {translatable(content.title) ? t(content.title) : content.title} , ); }); return elements; - }, [crumbs]); + }, [crumbs, disableLinks]); return (
diff --git a/client/app/bundles/course/container/Breadcrumbs/__test__/Breadcrumbs.test.tsx b/client/app/bundles/course/container/Breadcrumbs/__test__/Breadcrumbs.test.tsx new file mode 100644 index 00000000000..d5bc49b4dae --- /dev/null +++ b/client/app/bundles/course/container/Breadcrumbs/__test__/Breadcrumbs.test.tsx @@ -0,0 +1,42 @@ +import { CrumbData } from 'lib/hooks/router/dynamicNest'; +import { render } from 'test-utils'; + +import Breadcrumbs from '../Breadcrumbs'; + +const crumbs: CrumbData[] = [ + { + id: '1', + pathname: '/courses/1', + content: { url: '/courses/1', title: 'Course' }, + }, + { + id: '2', + pathname: '/courses/1/assessments', + content: { url: '/courses/1/assessments', title: 'Assessments' }, + }, + { + id: '3', + pathname: '/courses/1/assessments/2', + content: { title: 'Attempt' }, + }, +]; + +describe('Breadcrumbs', () => { + it('links every crumb except the last one', async () => { + const page = render(); + + expect((await page.findByText('Course')).closest('a')).not.toBeNull(); + expect( + (await page.findByText('Assessments')).closest('a'), + ).not.toBeNull(); + expect((await page.findByText('Attempt')).closest('a')).toBeNull(); + }); + + it('renders every crumb as inert text when disableLinks is set', async () => { + const page = render(); + + expect((await page.findByText('Course')).closest('a')).toBeNull(); + expect((await page.findByText('Assessments')).closest('a')).toBeNull(); + expect((await page.findByText('Attempt')).closest('a')).toBeNull(); + }); +}); diff --git a/client/app/bundles/course/container/Sidebar/Sidebar.tsx b/client/app/bundles/course/container/Sidebar/Sidebar.tsx index b1dfc9d0788..42630221523 100644 --- a/client/app/bundles/course/container/Sidebar/Sidebar.tsx +++ b/client/app/bundles/course/container/Sidebar/Sidebar.tsx @@ -36,6 +36,13 @@ const Sidebar = forwardRef, SidebarProps>( const { t } = useTranslation(); + // Home is the one sidebar entry that does not come from the `sidebar` payload, so the de-link + // `sidebar.json.jbuilder` applies to every other item never reached it. Withholding its path below + // makes `SidebarItem` render it as the same grey inert row as its neighbours. + const homeUrl = data.homeRedirectsToLearn + ? `${data.courseUrl}/home` + : data.courseUrl; + return ( , SidebarProps>( {data.sidebar && (
{data.sidebar.map((item) => ( diff --git a/client/app/bundles/course/container/Sidebar/SidebarItem.tsx b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx index 9efe99c77fb..97c27007dbf 100644 --- a/client/app/bundles/course/container/Sidebar/SidebarItem.tsx +++ b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx @@ -87,7 +87,7 @@ const SidebarItem = (props: SidebarItemProps): JSX.Element => { ); }; -const HomeSidebarItem = (props: { to: string }): JSX.Element => { +const HomeSidebarItem = (props: { to?: string }): JSX.Element => { return ( ({ + ...jest.requireActual('lib/containers/AppContainer'), + useAppContext: (): HomeLayoutData => ({ + locale: 'en', + timeZone: 'Asia/Singapore', + }), +})); + +const data: CourseLayoutData = { + courseTitle: 'Marketplace Preview', + courseUrl: '/courses/1', + courseUserUrl: '/courses/1/users/1', + userName: 'Previewer', + userId: 1, + sidebar: [ + { + key: 'sidebar_assessments', + path: '/courses/1/assessments', + icon: 'assessment', + }, + ], +}; + +describe('Sidebar', () => { + it('links Home to the course', async () => { + const page = render(); + + expect((await page.findByText('Home')).closest('a')).toHaveAttribute( + 'href', + '/courses/1', + ); + }); + + it('links Home to the learn page when the course home redirects there', async () => { + const page = render( + , + ); + + expect((await page.findByText('Home')).closest('a')).toHaveAttribute( + 'href', + '/courses/1/home', + ); + }); + + it('renders Home as inert text for a restricted previewer', async () => { + const page = render( + , + ); + + expect((await page.findByText('Home')).closest('a')).toBeNull(); + }); +}); diff --git a/client/app/types/course/courses.ts b/client/app/types/course/courses.ts index eee97652e39..67fde120b12 100644 --- a/client/app/types/course/courses.ts +++ b/client/app/types/course/courses.ts @@ -113,6 +113,10 @@ export interface CourseLayoutData { courseUserRole?: CourseUserRole; userAvatarUrl?: string; homeRedirectsToLearn?: boolean; + isPreview?: boolean; + // Whether the marketplace sandbox's read-only lock applies to THIS viewer. False for a system + // administrator, who curates the container from inside it, while `isPreview` stays true for them. + isPreviewRestricted?: boolean; sidebar?: SidebarItemData[]; adminSidebar?: SidebarItemData[]; manageEmailSubscriptionUrl?: string; diff --git a/spec/controllers/course/courses_controller_spec.rb b/spec/controllers/course/courses_controller_spec.rb index 38732d66a45..9cf0c007c11 100644 --- a/spec/controllers/course/courses_controller_spec.rb +++ b/spec/controllers/course/courses_controller_spec.rb @@ -127,6 +127,81 @@ end end + describe '#sidebar' do + render_views + + let(:user) { create(:user) } + let(:course) { create(:course, published: true) } + let!(:course_user) { create(:course_student, course: course, user: user) } + + subject { get :sidebar, as: :json, params: { id: course } } + + before { controller_sign_in(controller, user) } + + context 'when the course is a normal course' do + it 'includes path on sidebar items' do + subject + sidebar_items = JSON.parse(response.body)['sidebar'] + expect(sidebar_items).to be_present + expect(sidebar_items).to all(have_key('path')) + end + end + + context 'when the course is a preview course' do + let(:course) { create(:course, published: true, preview: true) } + + it 'omits path on sidebar items' do + subject + sidebar_items = JSON.parse(response.body)['sidebar'] + expect(sidebar_items).to be_present + expect(sidebar_items).not_to include(a_hash_including('path')) + end + + it 'reports the sandbox as restricted' do + subject + expect(JSON.parse(response.body)['isPreviewRestricted']).to eq(true) + end + end + + context 'when a system administrator views a preview course' do + let(:user) { create(:administrator) } + let(:course) { create(:course, published: true, preview: true) } + let!(:course_user) { create(:course_manager, course: course, user: user) } + + it 'keeps sidebar items linked' do + subject + sidebar_items = JSON.parse(response.body)['sidebar'] + expect(sidebar_items).to be_present + expect(sidebar_items).to all(have_key('path')) + end + + it 'reports the sandbox as unrestricted' do + subject + expect(JSON.parse(response.body)['isPreviewRestricted']).to eq(false) + end + + it 'does not present them as a member of the sandbox' do + subject + expect(JSON.parse(response.body)).not_to have_key('courseUserRole') + end + end + + context 'when a system administrator views a normal course' do + let(:user) { create(:administrator) } + let!(:course_user) { create(:course_manager, course: course, user: user) } + + it 'presents them as a member' do + subject + expect(JSON.parse(response.body)['courseUserRole']).to eq('manager') + end + end + + it 'exposes isPreview matching the course' do + subject + expect(JSON.parse(response.body)['isPreview']).to eq(course.preview) + end + end + describe '#index' do context 'when there is no user logged in' do it 'allows unauthenticated access' do From ee9d06f074cd3966a3172a37f4be79fd322b6ba0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:35:32 +0800 Subject: [PATCH 18/28] feat(marketplace): show the sandbox explainer on the container's home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everyone in the sandbox is enrolled, so `CourseShow` skipped the description — yet it is the only thing on that page, since the container has no announcement, todo or activity of its own. Reached only by the administrators who curate the container: the sandbox lock denies this page to a restricted previewer, who is landed straight on a submission and told the same thing by the banner there. --- .../pages/CourseShow/__test__/index.test.tsx | 89 +++++++++++++++++++ .../course/courses/pages/CourseShow/index.tsx | 38 +++++--- 2 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 client/app/bundles/course/courses/pages/CourseShow/__test__/index.test.tsx diff --git a/client/app/bundles/course/courses/pages/CourseShow/__test__/index.test.tsx b/client/app/bundles/course/courses/pages/CourseShow/__test__/index.test.tsx new file mode 100644 index 00000000000..c1bb3f9b62b --- /dev/null +++ b/client/app/bundles/course/courses/pages/CourseShow/__test__/index.test.tsx @@ -0,0 +1,89 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; +import { useCourseContext } from 'course/container/CourseLoader'; + +import CourseShow from '../index'; + +// `TestApp` mounts the component inside a `MemoryRouter` with no matching ``, +// so `useParams()` would otherwise be empty and the page would never fetch (mirrors +// marketplace/pages/ListingPreview/__test__). +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: (): jest.Mock => jest.fn(), + useParams: (): { courseId: string } => ({ + courseId: global.courseId.toString(), + }), +})); + +// There is no CourseLayout outlet in the test, so the preview flag comes from a mocked hook +// (mirrors SubmissionEditIndex/components/button/__test__/PublishButton). +jest.mock('course/container/CourseLoader', () => ({ + useCourseContext: jest.fn(), +})); + +const mockUseCourseContext = useCourseContext as jest.Mock; + +const mock = createMockAdapter(CourseAPI.courses.client); +beforeEach(() => { + mock.reset(); + mockUseCourseContext.mockReturnValue({ isPreview: false }); +}); + +const DESCRIPTION = 'Welcome to the Marketplace Preview Sandbox'; + +const replyWithCourse = ( + overrides: Record = {}, +): Record => { + const course = { + id: Number(global.courseId), + title: 'Marketplace Preview Sandbox', + description: `

${DESCRIPTION}

`, + logoUrl: '', + instructors: [], + registrationInfo: null, + isSuspended: false, + canSuspendCourse: false, + isSuspendedUser: false, + permissions: { isCurrentCourseUser: true, canManage: true }, + ...overrides, + }; + + mock.onGet(`/courses/${global.courseId}`).reply(200, { course }); + return course; +}; + +it('renders the course description inside the preview sandbox', async () => { + mockUseCourseContext.mockReturnValue({ isPreview: true }); + replyWithCourse(); + + render(); + + await waitFor(() => expect(screen.getByText('Description')).toBeVisible()); + expect(screen.getByText(DESCRIPTION)).toBeVisible(); +}); + +it('keeps the description hidden from an enrolled user of a normal course', async () => { + replyWithCourse(); + + render(); + + // Nothing else renders for an enrolled user of an activity-free course, so wait for the fetch to + // settle via the store rather than via an element. + await waitFor(() => expect(mock.history.get).toHaveLength(1)); + expect(screen.queryByText('Description')).not.toBeInTheDocument(); +}); + +it('still shows the description to a user who is not enrolled', async () => { + replyWithCourse({ + permissions: { isCurrentCourseUser: false, canManage: false }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('Description')).toBeVisible()); + expect(screen.getByText(DESCRIPTION)).toBeVisible(); + // The non-enrolled view is unchanged: description sits above the instructors section. + expect(screen.getByText('Instructors')).toBeVisible(); +}); diff --git a/client/app/bundles/course/courses/pages/CourseShow/index.tsx b/client/app/bundles/course/courses/pages/CourseShow/index.tsx index ab3dc59675c..e26854f115c 100644 --- a/client/app/bundles/course/courses/pages/CourseShow/index.tsx +++ b/client/app/bundles/course/courses/pages/CourseShow/index.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { Typography } from '@mui/material'; import { CourseEntity } from 'types/course/courses'; +import { useCourseContext } from 'course/container/CourseLoader'; import AvatarWithLabel from 'lib/components/core/AvatarWithLabel'; import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; @@ -46,6 +47,8 @@ const CourseShow: FC = () => { const navigate = useNavigate(); const { courseId } = useParams(); const course = useAppSelector((state) => getCourseEntity(state, +courseId!)); + // Optional-chained: this page also renders outside a CourseLayout outlet (see PublishButton). + const isPreview = useCourseContext()?.isPreview; useEffect(() => { if (courseId) { @@ -71,6 +74,18 @@ const CourseShow: FC = () => { return null; } + const descriptionSection = course.description.trim() ? ( +
+ {t(translations.descriptionHeader)} + + +
+ ) : null; + return ( {course.isSuspended && ( @@ -87,19 +102,7 @@ const CourseShow: FC = () => {
)} - {course.description.trim() && ( -
- - {t(translations.descriptionHeader)} - - - -
- )} + {descriptionSection}
@@ -125,6 +128,15 @@ const CourseShow: FC = () => { )} + {/* Everyone in the marketplace preview sandbox is enrolled (as a manager), so the description + above is skipped there — yet it is the only thing on the page, since the sandbox has no + announcement, todo or activity of its own. Reached only by the administrators who curate the + container: ApplicationPreviewSandboxConcern denies this page to a restricted previewer, who + is landed straight on a submission and told the same thing by PreviewCourseBanner. */} + {course.permissions.isCurrentCourseUser && + isPreview && + descriptionSection} + {(course.permissions.isCurrentCourseUser || course.permissions.canManage) && ( <> From 5393d465456efa6c965fbbbd8fcc0fb24efeab12 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:36:03 +0800 Subject: [PATCH 19/28] feat(marketplace): let a previewer reset their own preview submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unique_assessment_id_and_creator_id` allows one submission per (assessment, previewer), and launching a preview resumes it rather than resetting it — so without this, starting over means waiting for the TTL. `Submission#reset_preview!` clears every answer, not just the current ones: a previewer who finalises and unsubmits accumulates Past Answers, which is exactly the trace a reset is meant to erase. It does not reuse `unsubmit`'s `recreate_current_answers`, which preserves in-progress drafts, copies the old content forward as `last_attempt`, and keeps the old rows as history — all correct for a real course, all wrong here. The endpoint never accepts a submission id from the client: the row is always looked up by (assessment, creator), which makes resetting someone else's attempt structurally impossible before the ability check even runs. --- .../preview_submissions_controller.rb | 58 +++ app/models/course/assessment/submission.rb | 91 ++++- config/routes.rb | 1 + .../preview_submissions_controller_spec.rb | 360 ++++++++++++++++++ 4 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 app/controllers/course/assessment/marketplace/preview_submissions_controller.rb create mode 100644 spec/controllers/course/assessment/marketplace/preview_submissions_controller_spec.rb diff --git a/app/controllers/course/assessment/marketplace/preview_submissions_controller.rb b/app/controllers/course/assessment/marketplace/preview_submissions_controller.rb new file mode 100644 index 00000000000..c375cf1991d --- /dev/null +++ b/app/controllers/course/assessment/marketplace/preview_submissions_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# Lets a marketplace previewer self-service reset THEIR OWN submission for an assessment inside the +# "Marketplace Preview Sandbox" container course, so they can relaunch "Try it hands-on" for a +# genuinely fresh attempt (Course::Assessment::Marketplace::PreviewLaunchService otherwise resumes +# an existing submission rather than resetting it). +# +# The container course is shared by every previewer (see PreviewContainerService), and every +# previewer is enrolled as `manager` — the lowest role that can attempt+grade+publish. Under +# Course::Assessment::AssessmentAbility#allow_manager_delete_assessment_submissions, a manager holds +# `:delete_submission` on ANY submission in the course, not just their own, so +# Course::AssessmentMarketplaceAbilityComponent revokes that verb wholesale inside a preview course +# and grants a distinct, `creator_id`-scoped `:reset_own_preview_submission` instead. This endpoint +# also never accepts a submission id from the client: the submission to reset is always looked +# up by (assessment, creator: current_user) — the same lookup +# Course::Assessment::Submission::SubmissionsController#create uses to decide "resume, don't +# reset" — which makes resetting another previewer's submission structurally impossible +# regardless of what a client sends, even before the ability check runs. +# +# This is `update`, not `destroy`: the submission row is kept (same id) so the previewer stays on +# the same submission edit page and sees it come back blank, rather than being redirected away. +# See Course::Assessment::Submission#reset_preview! for the clear-and-reset logic. +class Course::Assessment::Marketplace::PreviewSubmissionsController < Course::Assessment::Marketplace::Controller + before_action :ensure_preview_course! + before_action :load_assessment + + def update + submission = @assessment.submissions.find_by(creator: current_user) + return head :not_found unless submission + + authorize!(:reset_own_preview_submission, submission) + submission.reset_preview! + head :no_content + end + + protected + + # The one action written for previewers, and the only one they can reach that is not also an + # ordinary course action. The submission id never comes from the client (see above), so nothing here + # needs vetting beyond `ensure_preview_course!`. + def preview_sandbox_accessible? + true + end + + private + + # This self-service shortcut only ever exists inside the preview sandbox. A real course's + # teaching staff already have a vetted removal flow + # (Course::Assessment::Submission::SubmissionsController#delete/#delete_all); this action + # deliberately skips that flow's randomization/monitoring bookkeeping, since preview assessments + # are plain content-frozen copies with neither feature configured. + def ensure_preview_course! + raise CanCan::AccessDenied unless current_course.preview? + end + + def load_assessment + @assessment = current_course.assessments.find(params[:assessment_id]) + end +end diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index cb5df4c80f6..ce0f607d42c 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -9,6 +9,13 @@ class Course::Assessment::Submission < ApplicationRecord attr_accessor :has_unsubmitted_or_draft_answer + # The AutoGradingJob enqueued by *this* instance's finalising save, if it performed one. Assigned + # by `auto_grade_submission`'s after-commit block, which the `save` runs before the controller + # renders — so the response can hand the client a job url to poll (see the marketplace preview + # sandbox). Nil on every other request, since it lives only on the in-memory instance that made + # the transition. + attr_reader :auto_grading_job + acts_as_experience_points_record FORCE_SUBMIT_DELAY = 5.minutes @@ -217,6 +224,44 @@ def unsubmitting? !!@unsubmitting end + # Marketplace preview sandbox only: unconditionally clears every answer back to a fresh blank + # attempt and forces the submission's own state back to a pristine :attempting, regardless + # of what state the submission was in beforehand (including if it is already :attempting). + # + # This deliberately does NOT reuse WorkflowEventConcern#unsubmit/#recreate_current_answers: + # - `recreate_current_answers` skips answers that are still `attempting?`, since real-course + # `unsubmit` (a staff action) must never wipe a real student's in-progress draft. A preview + # "Reset Submission" has no such answer to protect — it must clear EVERYTHING. + # - `recreate_current_answers` calls `question.attempt(submission, current_answer)`, passing the + # old answer as `last_attempt` — every question type's `#attempt` COPIES `last_attempt`'s + # content forward when given one (see e.g. Question::MultipleResponse#attempt). That is + # correct for `unsubmit` (resume editing what you had), but wrong here: a reset must produce a + # genuinely blank answer, so `last_attempt` is omitted below. + # - `recreate_current_answers` merely flips the old answer's `current_answer` to false and keeps + # it as audit history. A preview reset has no audit requirement to serve, and the user wants a + # genuinely clean slate (no "Past Answers" trace, no leftover programming test-case run + # results), so every answer is destroyed outright — including answers that are ALREADY past + # history, which a previewer can accumulate by finalising and then unsubmitting (they hold + # `can :manage` on their own submission inside the sandbox). See reset_preview_answers below. + # - `unsubmit!`'s state machine event is only a valid transition FROM :submitted/:published, so it + # cannot be called when the submission is already :attempting. This method assigns + # `workflow_state` directly instead of going through the event, so it works from any state. + def reset_preview! + transaction do + # Flip the submission's own state to :attempting FIRST (in memory only; not saved yet). Every + # new blank answer built below defaults to :attempting too, and Answer#validate_assessment_state + # requires `submission.attempting?` for an :attempting answer to be valid — reassigning after + # the loop (rather than before) would make each `new_answer.save!` fail validation, since the + # in-memory submission (shared with its answers via `inverse_of`) would still read as + # :submitted/:graded/:published at that point. + reset_preview_workflow_attributes + reset_preview_answers + answers.reload + + save! + end + end + def submission_view_blocked?(course_user) !attempting? && !published? && assessment.block_student_viewing_after_submitted? && course_user&.student? end @@ -346,13 +391,57 @@ def self.grade_summary(student_ids:, assessment_ids:) private + # See Submission#reset_preview!. + def reset_preview_workflow_attributes + self.workflow_state = 'attempting' + self.points_awarded = nil + self.draft_points_awarded = nil + self.awarded_at = nil + self.awarder = nil + self.submitted_at = nil + self.publisher = nil + self.published_at = nil + end + + # See Submission#reset_preview!. + # + # Unlike recreate_current_answers (unsubmit, real courses), the answers are DESTROYED here, not + # just flipped to non-current. A preview reset has no real-student audit history to preserve, and + # the user wants a genuinely clean slate — no "Past Answers" trace, and for programming questions + # specifically, no leftover test-case run results/output from the previous attempt. + # + # Every answer goes, not just the current ones: answers already flipped to non-current by an + # earlier finalise/unsubmit round are exactly the "Past Answers" trace the reset is meant to erase. + # The fresh blanks are built from `current_answers` (one per question) BEFORE anything is + # destroyed, and `stale_answers` is snapshotted first so the new rows are never in that list. + # + # `Answer#destroy!` is enough on its own to take all of that with it: Answer's own + # `has_one :auto_grading, dependent: :destroy` (answer.rb) destroys the AutoGrading row, whose + # `actable` `belongs_to`, set up by the `actable` macro with `dependent: :destroy` + # (active_record-acts_as), destroys its specific type (e.g. ProgrammingAutoGrading) — which in turn + # `has_many :test_results, dependent: :destroy` (programming_auto_grading.rb). Answer's own + # `actable` `belongs_to` (also `dependent: :destroy`) likewise destroys the answer's specific type + # (e.g. Programming, whose own `has_many :files, dependent: :destroy` cascades further). No + # `dependent: :destroy` gap was found in this chain. + def reset_preview_answers + stale_answers = answers.to_a + + current_answers.each do |current_answer| + new_answer = current_answer.question.attempt(current_answer.submission) + new_answer.current_answer = true + new_answer.save! + end + + stale_answers.each(&:destroy!) + end + # Queues the submission for auto grading, after the submission has changed to the submitted state. def auto_grade_submission return unless saved_change_to_workflow_state? execute_after_commit do # Grade only ungraded answers regardless of state as we dont want to regrade graded/evaluated answers. - auto_grade!(only_ungraded: true) + @auto_grading_job = auto_grade!(only_ungraded: true) end end diff --git a/config/routes.rb b/config/routes.rb index 395ef198a68..8c8879f5d97 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -306,6 +306,7 @@ resource :marketplace_adoption, only: [] do post 'apply_latest_version' => 'marketplace_adoptions#apply_latest_version' end + resource :preview_submission, only: [:update], controller: 'marketplace/preview_submissions' namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do diff --git a/spec/controllers/course/assessment/marketplace/preview_submissions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/preview_submissions_controller_spec.rb new file mode 100644 index 00000000000..8e279a31067 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/preview_submissions_controller_spec.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PreviewSubmissionsController, type: :controller do + let(:instance) { create(:instance) } + + with_tenant(:instance) do + let(:preview_course) { create(:course, preview: true) } + let(:assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let(:question) { assessment.questions.first.actable } + let(:previewer) { create(:user) } + let!(:previewer_course_user) { create(:course_manager, course: preview_course, user: previewer) } + + before { controller_sign_in(controller, previewer) } + + describe 'PATCH #update' do + subject do + patch :update, params: { course_id: preview_course, assessment_id: assessment, format: :json } + end + + context 'when the previewer has a submission for this assessment' do + let!(:submission) do + create(:submission, :attempting, assessment: assessment, course: preview_course, creator: previewer) + end + let!(:original_answer) do + answer = submission.reload.answers.first.specific + answer.options << question.options.first + answer.save! + answer + end + + it 'does not destroy the submission row' do + expect { subject }. + not_to(change { Course::Assessment::Submission.exists?(submission.id) }) + end + + it 'clears the current answer back to blank, destroying the old one outright' do + # `original_answer` is the MultipleResponse actable, not the base Answer — resolve back to + # the base Answer's own id (`acting_as`, aliased by the `acts_as` macro) BEFORE calling + # `subject`: the two records live in different tables with unrelated ids, and once the + # base Answer is destroyed, `original_answer.acting_as` can no longer look it up. + original_base_answer_id = original_answer.acting_as.id + + expect { subject }.not_to(change { submission.reload.answers.count }) + + submission.reload + expect(submission.current_answers.length).to eq(1) + + new_current_answer = submission.current_answers.first.specific + expect(new_current_answer.id).not_to eq(original_answer.id) + expect(new_current_answer.option_ids).to be_empty + + expect { Course::Assessment::Answer.find(original_base_answer_id) }. + to raise_error(ActiveRecord::RecordNotFound) + end + + it 'leaves an already-attempting submission in the attempting state' do + expect(submission.workflow_state).to eq('attempting') + subject + expect(submission.reload.workflow_state).to eq('attempting') + end + + it 'responds with no content' do + subject + expect(response).to have_http_status(:no_content) + end + end + + # Submission#reset_preview_answers loops over `current_answers`, one per question. A fixture + # with only one question cannot tell that loop apart from one that resets just the first answer. + context 'when the assessment has more than one question' do + let(:two_question_assessment) do + create(:assessment, :with_mcq_question, question_count: 2, course: preview_course) + end + let!(:submission) do + create(:submission, :attempting, assessment: two_question_assessment, course: preview_course, + creator: previewer) + end + + subject do + patch :update, params: { course_id: preview_course, assessment_id: two_question_assessment, + format: :json } + end + + it 'replaces every current answer, not just the first' do + old_answer_ids = submission.reload.current_answers.map(&:id) + expect(old_answer_ids.length).to eq(2) + + subject + + submission.reload + expect(submission.current_answers.length).to eq(2) + expect(Course::Assessment::Answer.where(id: old_answer_ids)).to be_empty + end + end + + # The user's real bug report: for a programming question, the old current answer's + # auto-grading record (stdout/stderr/exit code) and its per-test-case pass/fail rows must not + # survive a reset either — otherwise the "Past Answers" list and old test results still show + # up on the freshly-reset page. + context "when the previewer's current answer has programming test-case run results (auto-grading)" do + let(:programming_assessment) { create(:assessment, :with_programming_question, course: preview_course) } + let!(:submission) do + create(:submission, :attempting, assessment: programming_assessment, course: preview_course, + creator: previewer) + end + let(:programming_answer) { submission.reload.answers.first } + let!(:auto_grading) do + create(:course_assessment_answer_programming_auto_grading, answer: programming_answer) + end + let!(:test_result) do + create(:course_assessment_answer_programming_auto_grading_test_result, :failed, auto_grading: auto_grading) + end + + subject do + patch :update, params: { course_id: preview_course, assessment_id: programming_assessment, format: :json } + end + + it 'destroys the old answer, its auto-grading record, and its test-case results' do + programming_answer_id = programming_answer.id + auto_grading_id = auto_grading.id + test_result_id = test_result.id + + subject + + expect { Course::Assessment::Answer.find(programming_answer_id) }. + to raise_error(ActiveRecord::RecordNotFound) + expect { Course::Assessment::Answer::ProgrammingAutoGrading.find(auto_grading_id) }. + to raise_error(ActiveRecord::RecordNotFound) + expect { Course::Assessment::Answer::ProgrammingAutoGradingTestResult.find(test_result_id) }. + to raise_error(ActiveRecord::RecordNotFound) + end + end + + # A previewer holds `can :manage` on their own submission inside the sandbox, so they can + # finalise and then unsubmit — and unsubmit demotes the old answer to non-current instead of + # destroying it. Those leftover rows ARE the "Past Answers" trace the reset exists to erase, + # so they must go too, not just the current answer. + context 'when the previewer already has past (non-current) answers' do + let!(:submission) do + create(:submission, :attempting, assessment: assessment, course: preview_course, creator: previewer) + end + # Built by hand rather than with the `:attempting_with_past_answers` trait: that trait's + # `questions.attempt(submission)` REUSES an existing current answer instead of building a + # second one (QuestionsConcern#attempt:15), so it demotes the only answer there is and + # leaves the submission with a past answer and no current one. + let!(:past_answer) do + answer = assessment.questions.first.attempt(submission) + answer.current_answer = false + answer.save! + answer + end + + it 'destroys the past answers too, leaving one fresh blank current answer' do + past_answer_id = past_answer.id + expect(submission.reload.answers.count).to eq(2) + + expect { subject }.to change { submission.reload.answers.count }.from(2).to(1) + + expect(Course::Assessment::Answer.where(id: past_answer_id)).to be_empty + expect(submission.current_answers.length).to eq(1) + end + end + + context 'when the submission was already submitted (past attempting)' do + let!(:submission) do + create(:submission, :submitted, assessment: assessment, course: preview_course, creator: previewer) + end + + it 'forces the workflow_state back to attempting' do + expect(submission.workflow_state).to eq('submitted') + subject + expect(submission.reload.workflow_state).to eq('attempting') + end + + it 'clears submitted_at' do + expect(submission.submitted_at).not_to be_nil + subject + expect(submission.reload.submitted_at).to be_nil + end + end + + context 'when the submission was graded but not yet published' do + let!(:submission) do + create(:submission, :graded, assessment: assessment, course: preview_course, creator: previewer) + end + + it 'forces the workflow_state back to attempting and clears the draft grade' do + expect(submission.workflow_state).to eq('graded') + expect(submission.draft_points_awarded).not_to be_nil + + subject + submission.reload + + expect(submission.workflow_state).to eq('attempting') + expect(submission.draft_points_awarded).to be_nil + end + end + + context 'when the submission was already graded and published' do + let!(:submission) do + create(:submission, :published, assessment: assessment, course: preview_course, creator: previewer) + end + + it 'forces the workflow_state back to attempting' do + subject + expect(submission.reload.workflow_state).to eq('attempting') + end + + it 'clears all grading/publishing attributes' do + expect(submission.points_awarded).not_to be_nil + expect(submission.draft_points_awarded).not_to be_nil + expect(submission.awarded_at).not_to be_nil + expect(submission.awarder).not_to be_nil + expect(submission.publisher).not_to be_nil + expect(submission.published_at).not_to be_nil + + subject + submission.reload + + expect(submission.points_awarded).to be_nil + expect(submission.draft_points_awarded).to be_nil + expect(submission.awarded_at).to be_nil + expect(submission.awarder).to be_nil + expect(submission.submitted_at).to be_nil + expect(submission.publisher).to be_nil + expect(submission.published_at).to be_nil + end + end + + context 'when the current previewer has no submission for this assessment' do + it 'responds not found' do + subject + expect(response).to have_http_status(:not_found) + end + end + + # The submission is looked up through `@assessment.submissions`, never by an id from the + # client, so a submission the previewer owns for a DIFFERENT assessment in the same sandbox + # must not be reachable through this route either. + context 'when the previewer only has a submission for another assessment in the same course' do + let(:other_assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let!(:other_assessment_submission) do + create(:submission, :submitted, assessment: other_assessment, course: preview_course, creator: previewer) + end + + it 'responds not found' do + subject + expect(response).to have_http_status(:not_found) + end + + it "does not reset the other assessment's submission" do + subject + expect(other_assessment_submission.reload.workflow_state).to eq('submitted') + end + end + + # The container course is shared by every previewer, and every previewer is enrolled as + # `manager` — which under Course::Assessment::AssessmentAbility grants `:delete_submission` + # on ANY submission in the course, not just their own. This is the single riskiest scoping + # question in this feature, so it gets its own dedicated set of examples. + context "when another previewer's submission exists for the same assessment" do + let(:other_previewer) { create(:user) } + let!(:other_course_user) { create(:course_manager, course: preview_course, user: other_previewer) } + let!(:other_submission) do + create(:submission, :attempting, assessment: assessment, course: preview_course, creator: other_previewer) + end + + it "does not touch the other previewer's submission" do + other_workflow_state_before = other_submission.workflow_state + other_answer_count_before = other_submission.answers.count + + subject + + other_submission.reload + expect(other_submission.workflow_state).to eq(other_workflow_state_before) + expect(other_submission.answers.count).to eq(other_answer_count_before) + end + + it 'responds not found (the current previewer has nothing of their own to reset)' do + subject + expect(response).to have_http_status(:not_found) + end + end + + context 'when both the current previewer and another previewer have their own submissions' do + let(:other_previewer) { create(:user) } + let!(:other_course_user) { create(:course_manager, course: preview_course, user: other_previewer) } + let!(:own_submission) do + create(:submission, :submitted, assessment: assessment, course: preview_course, creator: previewer) + end + let!(:other_submission) do + create(:submission, :submitted, assessment: assessment, course: preview_course, creator: other_previewer) + end + + it "resets only the current previewer's own submission" do + subject + + expect(own_submission.reload.workflow_state).to eq('attempting') + expect(other_submission.reload.workflow_state).to eq('submitted') + end + end + + # `load_assessment` scopes the lookup to `current_course.assessments`, so an assessment id + # from another course cannot be driven through the preview course's route. + context 'when the assessment belongs to a different course' do + let(:other_course) { create(:course) } + let(:other_course_assessment) { create(:assessment, :with_mcq_question, course: other_course) } + + subject do + patch :update, params: { course_id: preview_course, assessment_id: other_course_assessment, + format: :json } + end + + it 'raises not found' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + # Enrolment is what admits someone to the sandbox: PreviewLaunchService enrols each previewer + # as a manager of the container course, and Course::Controller's + # `load_and_authorize_resource :course` denies anyone without a CourseUser there. + context 'when the user is not enrolled in the preview course' do + let(:outsider) { create(:user) } + + before { controller_sign_in(controller, outsider) } + + it 'denies access' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when the course is not a marketplace preview course' do + run_rescue + + let(:normal_course) { create(:course) } + let(:normal_assessment) { create(:assessment, :with_mcq_question, course: normal_course) } + let!(:normal_course_user) { create(:course_manager, course: normal_course, user: previewer) } + let!(:normal_submission) do + create(:submission, :attempting, assessment: normal_assessment, course: normal_course, creator: previewer) + end + + subject do + patch :update, params: { course_id: normal_course, assessment_id: normal_assessment, format: :json } + end + + it 'is forbidden' do + subject + expect(response).to have_http_status(:forbidden) + end + + it 'does not touch the submission' do + workflow_state_before = normal_submission.workflow_state + subject + expect(normal_submission.reload.workflow_state).to eq(workflow_state_before) + end + end + end + end +end From 6b8d3a09093252e667dd9ef8e426c450fba51214 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:36:11 +0800 Subject: [PATCH 20/28] feat(marketplace): add the "Try it hands-on" launch button Opens the sandbox in a new tab from a listing's preview page, behind a confirmation that says what will happen: a separate sandbox, possibly a sign-in redirect, and no effect on the viewer's own course. `navigateTo` exists as a seam, not an abstraction: jsdom seals the whole navigation surface, so a component that assigns `location.href` inline has no assertable behaviour and its test passes whether or not the branch still exists. --- client/app/api/course/Marketplace.ts | 10 + .../components/TryItHandsOnButton.tsx | 71 ++++++ .../bundles/course/marketplace/operations.ts | 13 ++ .../ListingPreview/__test__/index.test.tsx | 204 +++++++++++++++++- .../pages/ListingPreview/index.tsx | 20 +- .../course/marketplace/translations.ts | 38 ++++ client/app/lib/helpers/navigation.ts | 17 ++ client/locales/en.json | 15 ++ client/locales/ko.json | 15 ++ client/locales/zh.json | 15 ++ 10 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 client/app/bundles/course/marketplace/components/TryItHandsOnButton.tsx create mode 100644 client/app/lib/helpers/navigation.ts diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 93de9255995..ff8f0688862 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -59,6 +59,16 @@ export default class MarketplaceAPI extends BaseCourseAPI { return this.client.get(`${this.#urlPrefix}/listings/${id}`); } + launchPreview(id: number): Promise> { + return this.client.post(`${this.#urlPrefix}/listings/${id}/launch_preview`); + } + + resetPreviewSubmission(assessmentId: number): Promise { + return this.client.patch( + `/courses/${this.courseId}/assessments/${assessmentId}/preview_submission`, + ); + } + fetchQuestion(listingId: number, questionId: number): Promise { return this.client.get( `${this.#urlPrefix}/listings/${listingId}/questions/${questionId}`, diff --git a/client/app/bundles/course/marketplace/components/TryItHandsOnButton.tsx b/client/app/bundles/course/marketplace/components/TryItHandsOnButton.tsx new file mode 100644 index 00000000000..856be0ac58a --- /dev/null +++ b/client/app/bundles/course/marketplace/components/TryItHandsOnButton.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { OpenInNew } from '@mui/icons-material'; +import { Button } from '@mui/material'; + +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import { navigateTo } from 'lib/helpers/navigation'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { launchPreview } from '../operations'; +import translations from '../translations'; + +interface Props { + listingId: number; +} + +const TryItHandsOnButton = ({ listingId }: Props): JSX.Element => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const confirm = async (): Promise => { + // Opened synchronously with the click, before any `await` — a `window.open` issued after an + // `await` breaks the user-gesture chain and gets popup-blocked (most reliably in Safari). + const tab = window.open('', '_blank'); + setSubmitting(true); + try { + const { url } = await launchPreview(listingId); + if (tab) { + tab.location.href = url; + } else { + // The browser blocked the popup anyway; degrade to same-tab navigation rather than + // leaving a dead button. + navigateTo(url); + } + setOpen(false); + } catch { + // Never strand the user on a blank about:blank tab. + tab?.close(); + toast.error(t(translations.launchPreviewFailed)); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor="primary" + primaryLabel={t(translations.tryItHandsOn)} + title={t(translations.tryItHandsOnConfirmTitle)} + > + {t(translations.tryItHandsOnConfirmBody)} + + + ); +}; + +export default TryItHandsOnButton; diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts index ff0303ce4a8..311997e7b9f 100644 --- a/client/app/bundles/course/marketplace/operations.ts +++ b/client/app/bundles/course/marketplace/operations.ts @@ -40,6 +40,19 @@ export const fetchListing = async (id: number): Promise => { return response.data as ListingPreviewData; }; +// Plain request, not `pollJob` — launch_preview is synchronous and returns the sandbox url +// directly, unlike `duplicate` which hands back a job to poll. +export const launchPreview = async (id: number): Promise<{ url: string }> => { + const response = await CourseAPI.marketplace.launchPreview(id); + return response.data as { url: string }; +}; + +export const resetPreviewSubmission = async ( + assessmentId: number, +): Promise => { + await CourseAPI.marketplace.resetPreviewSubmission(assessmentId); +}; + export const fetchQuestion = async ( listingId: number, questionId: number, diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx index 0d7ac6cbbe1..ac810c9bdd1 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx @@ -1,10 +1,23 @@ import { createMockAdapter } from 'mocks/axiosMock'; -import { fireEvent, render, screen, waitFor } from 'test-utils'; +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; import CourseAPI from 'api/course'; +import { navigateTo } from 'lib/helpers/navigation'; +import toast from 'lib/hooks/toast'; import ListingPreview from '../index'; +// jsdom seals the navigation surface (`window.location`, `location.href` and `location.assign` are +// all non-configurable, and a real `href` assignment is swallowed silently), so the popup-blocked +// branch is only assertable through this seam. See lib/helpers/navigation. +jest.mock('lib/helpers/navigation', () => ({ navigateTo: jest.fn() })); + +const mockNavigateTo = navigateTo as jest.Mock; + +// The failure toast is asserted directly (not rendered), so a plain jest.fn() mock is enough — +// unlike DuplicateConfirmation's toast, ours never carries a ReactNode payload. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + const mockNavigate = jest.fn(); // `TestApp` mounts the component directly inside a `MemoryRouter` with no matching @@ -37,9 +50,16 @@ jest.mock('../../../../container/CourseLoader', () => ({ // real fetchListing run. Auto-mocking operations makes fetchListing return undefined, and Preload's // `while` callback then does `undefined.then` → "Cannot read properties of undefined (reading 'then')". const mock = createMockAdapter(CourseAPI.marketplace.client); -beforeEach(() => mock.reset()); +beforeEach(() => { + mock.reset(); + jest.clearAllMocks(); +}); const LISTING_TITLE = 'Published, All Question Types'; +const DESCRIPTION_HTML = '

desc

'; +// Both the page's top-right action and the confirmation dialog's primary button carry this label, +// which is why the dialog click is always scoped with `within(dialog)`. +const TRY_IT_HANDS_ON = 'Try it hands-on'; it('renders the read-only assessment config', async () => { const url = `/courses/${global.courseId}/marketplace/listings/7`; @@ -139,7 +159,7 @@ it('carries from_tab into the per-question detail links', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -176,7 +196,7 @@ it('navigates back to the marketplace carrying from_tab', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -202,7 +222,7 @@ it('renders a back button to the marketplace index', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -226,7 +246,7 @@ it('marks the page title as a preview', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -244,3 +264,175 @@ it('marks the page title as a preview', async () => { // for the real assessment it mirrors. expect(screen.getByText('Preview')).toBeVisible(); }); + +it('opens the hands-on preview interstitial', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/separate sandbox/)).toBeVisible(); +}); + +it('confirming posts to launch_preview and points the opened tab at the returned url', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + const previewUrl = + 'https://preview.sandbox.test/courses/9/assessments/3/attempt'; + // `TryItHandsOnButton` launches by the listing's own id (70), not the route param (7) — + // mirroring how `DuplicateConfirmation` on this same page keys its duplicate request off + // `listing.id`. + mock + .onPost( + `/courses/${global.courseId}/marketplace/listings/70/launch_preview`, + ) + .reply(200, { url: previewUrl }); + + // A fake tab standing in for the one `window.open('', '_blank')` returns synchronously with the + // click, before the launch_preview request resolves. + const tab = { location: {} as { href?: string }, close: jest.fn() }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(tab as unknown as Window); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); // trigger + + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: TRY_IT_HANDS_ON }), + ); // confirm — same label as the trigger, so scoped to the dialog + + // Opened synchronously with the click, not after the response lands. + expect(openSpy).toHaveBeenCalledWith('', '_blank'); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + await waitFor(() => expect(tab.location.href).toBe(previewUrl)); + // The marketplace tab stays put; only the blocked-popup fallback navigates it. + expect(mockNavigateTo).not.toHaveBeenCalled(); + + openSpy.mockRestore(); +}); + +it('navigates in place when the browser blocks the popup', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + const previewUrl = + 'https://preview.sandbox.test/courses/9/assessments/3/attempt'; + mock + .onPost( + `/courses/${global.courseId}/marketplace/listings/70/launch_preview`, + ) + .reply(200, { url: previewUrl }); + + // A blocked popup: `window.open` returns null even though the call rode the click's user gesture. + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); + + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: TRY_IT_HANDS_ON }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + // Degrades to same-tab navigation rather than leaving a dead button, and stays silent — the + // launch itself succeeded, so an error toast would be a lie. + await waitFor(() => expect(mockNavigateTo).toHaveBeenCalledWith(previewUrl)); + expect(toast.error).not.toHaveBeenCalled(); + + openSpy.mockRestore(); +}); + +it('closes the tab and toasts an error when the launch fails', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + mock + .onPost( + `/courses/${global.courseId}/marketplace/listings/70/launch_preview`, + ) + .reply(500); + + const tab = { location: {} as { href?: string }, close: jest.fn() }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(tab as unknown as Window); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); + + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: TRY_IT_HANDS_ON }), + ); + + // Never strand the user on a blank about:blank tab. + await waitFor(() => expect(tab.close).toHaveBeenCalled()); + expect(toast.error).toHaveBeenCalled(); + + openSpy.mockRestore(); +}); diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx index 5e6c9a04abd..47019299cbd 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -13,6 +13,7 @@ import Preload from 'lib/components/wrappers/Preload'; import useTranslation from 'lib/hooks/useTranslation'; import DuplicateConfirmation from '../../components/DuplicateConfirmation'; +import TryItHandsOnButton from '../../components/TryItHandsOnButton'; import { withFromTab } from '../../fromTab'; import { fetchListing } from '../../operations'; import translations from '../../translations'; @@ -40,14 +41,17 @@ const ListingPreview = (): JSX.Element => { {(listing): JSX.Element => ( setDuplicating(true)} - startIcon={} - variant="contained" - > - {t(translations.duplicateAssessment)} - + <> + + + } backTo={withFromTab(`${courseUrl}/marketplace`, fromTab)} className="space-y-5" diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index f72446e0f71..e68f81a17d8 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -94,6 +94,44 @@ export default defineMessages({ id: 'course.marketplace.duplicateAssessment', defaultMessage: 'Duplicate Assessment', }, + tryItHandsOn: { + id: 'course.marketplace.tryItHandsOn', + defaultMessage: 'Try it hands-on', + }, + tryItHandsOnConfirmTitle: { + id: 'course.marketplace.tryItHandsOnConfirmTitle', + defaultMessage: 'Try it hands-on?', + }, + tryItHandsOnConfirmBody: { + id: 'course.marketplace.tryItHandsOnConfirmBody', + defaultMessage: + 'This opens a hands-on preview in a separate sandbox, in a new tab. You may be briefly redirected to sign in. Your own course is not affected.', + }, + launchPreviewFailed: { + id: 'course.marketplace.launchPreviewFailed', + defaultMessage: 'Could not launch the preview.', + }, + resetSubmission: { + id: 'course.marketplace.resetSubmission', + defaultMessage: 'Reset submission', + }, + resetSubmissionConfirmTitle: { + id: 'course.marketplace.resetSubmissionConfirmTitle', + defaultMessage: 'Reset your submission?', + }, + resetSubmissionConfirmBody: { + id: 'course.marketplace.resetSubmissionConfirmBody', + defaultMessage: + 'This clears all your answers for this assessment in the preview sandbox back to a blank state. You’ll stay on this page and can start answering again right away.', + }, + resetSubmissionSuccess: { + id: 'course.marketplace.resetSubmissionSuccess', + defaultMessage: 'Submission reset.', + }, + resetSubmissionFailed: { + id: 'course.marketplace.resetSubmissionFailed', + defaultMessage: 'Could not reset the submission.', + }, viewDetails: { id: 'course.marketplace.viewDetails', defaultMessage: 'View question details', diff --git a/client/app/lib/helpers/navigation.ts b/client/app/lib/helpers/navigation.ts new file mode 100644 index 00000000000..ccec5fb1516 --- /dev/null +++ b/client/app/lib/helpers/navigation.ts @@ -0,0 +1,17 @@ +/** + * Performs a full-page navigation, leaving the SPA. + * + * Exists as a seam, not an abstraction: jsdom seals the whole navigation surface — `window.location`, + * `location.href` and `location.assign` are all non-configurable, and a real `href` assignment is + * swallowed with a virtual-console warning instead of throwing. So a component that navigates + * inline has no assertable behaviour, and a test of that branch passes whether or not the branch + * still exists. Routing through this module gives tests something to `jest.mock`. + * + * Use it where a full page load is genuinely wanted (a cross-instance url, a Rails-rendered page). + * Within the SPA, prefer react-router's `useNavigate`. + * + * @param url The url to navigate to. + */ +export const navigateTo = (url: string): void => { + window.location.href = url; +}; diff --git a/client/locales/en.json b/client/locales/en.json index eeb4b033950..becfca56255 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6200,6 +6200,21 @@ "course.marketplace.emptyNoMatch": { "defaultMessage": "No assessments match your search." }, + "course.marketplace.resetSubmission": { + "defaultMessage": "Reset submission" + }, + "course.marketplace.resetSubmissionConfirmTitle": { + "defaultMessage": "Reset your submission?" + }, + "course.marketplace.resetSubmissionConfirmBody": { + "defaultMessage": "This clears all your answers for this assessment in the preview sandbox back to a blank state. You’ll stay on this page and can start answering again right away." + }, + "course.marketplace.resetSubmissionSuccess": { + "defaultMessage": "Submission reset." + }, + "course.marketplace.resetSubmissionFailed": { + "defaultMessage": "Could not reset the submission." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 4c09aad0491..242946a9725 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6155,6 +6155,21 @@ "course.marketplace.emptyNoMatch": { "defaultMessage": "검색과 일치하는 평가가 없습니다." }, + "course.marketplace.resetSubmission": { + "defaultMessage": "제출 재설정" + }, + "course.marketplace.resetSubmissionConfirmTitle": { + "defaultMessage": "제출을 재설정하시겠습니까?" + }, + "course.marketplace.resetSubmissionConfirmBody": { + "defaultMessage": "미리보기 샌드박스에서 이 평가에 작성한 답안이 모두 빈 상태로 초기화됩니다. 이 페이지에 그대로 머물면서 바로 다시 답안을 작성할 수 있습니다." + }, + "course.marketplace.resetSubmissionSuccess": { + "defaultMessage": "제출이 재설정되었습니다." + }, + "course.marketplace.resetSubmissionFailed": { + "defaultMessage": "제출을 재설정할 수 없습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 4b53a63a4d1..6d2a1d49a3e 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6149,6 +6149,21 @@ "course.marketplace.emptyNoMatch": { "defaultMessage": "没有符合搜索条件的评估。" }, + "course.marketplace.resetSubmission": { + "defaultMessage": "重置提交" + }, + "course.marketplace.resetSubmissionConfirmTitle": { + "defaultMessage": "要重置你的提交吗?" + }, + "course.marketplace.resetSubmissionConfirmBody": { + "defaultMessage": "这会将你在预览沙盒中对此评估的所有答案清空为初始状态。你会留在本页面,可以立即重新开始作答。" + }, + "course.marketplace.resetSubmissionSuccess": { + "defaultMessage": "提交已重置。" + }, + "course.marketplace.resetSubmissionFailed": { + "defaultMessage": "无法重置提交。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, From 8fdab023b7a317723176ae4d7c9083281e4ee446 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:36:17 +0800 Subject: [PATCH 21/28] feat(marketplace): add the preview sandbox banner and its Reset action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Says what the sandbox is on the page a previewer is actually landed on, and hosts the Reset submission button there. Mounted by `CourseContainer` because the flag lives on the layout payload, the only data available on every course page — but it narrows itself to submission pages: the promise it makes is about work a previewer produces, and the action it hosts has nothing to act on elsewhere. Gated on `isPreview`, not `isPreviewRestricted`: "nothing here is real" is a fact about the course, equally worth saying to an administrator, who also needs the Reset action. Only the LOCK is per-viewer. --- .../course/container/CourseContainer.tsx | 19 ++++ .../course/container/PreviewCourseBanner.tsx | 54 +++++++++++ .../__tests__/PreviewCourseBanner.test.tsx | 88 +++++++++++++++++ .../components/ResetSubmissionButton.tsx | 84 ++++++++++++++++ .../__test__/ResetSubmissionButton.test.tsx | 96 +++++++++++++++++++ 5 files changed, 341 insertions(+) create mode 100644 client/app/bundles/course/container/PreviewCourseBanner.tsx create mode 100644 client/app/bundles/course/container/__tests__/PreviewCourseBanner.test.tsx create mode 100644 client/app/bundles/course/marketplace/components/ResetSubmissionButton.tsx create mode 100644 client/app/bundles/course/marketplace/components/__test__/ResetSubmissionButton.test.tsx diff --git a/client/app/bundles/course/container/CourseContainer.tsx b/client/app/bundles/course/container/CourseContainer.tsx index 03c31b05368..975fc834ffe 100644 --- a/client/app/bundles/course/container/CourseContainer.tsx +++ b/client/app/bundles/course/container/CourseContainer.tsx @@ -17,6 +17,7 @@ import useTranslation, { translatable } from 'lib/hooks/useTranslation'; import Breadcrumbs from './Breadcrumbs'; import { loader, useCourseLoader } from './CourseLoader'; +import PreviewCourseBanner from './PreviewCourseBanner'; import Sidebar from './Sidebar'; const CourseContainer = (): JSX.Element => { @@ -63,11 +64,29 @@ const CourseContainer = (): JSX.Element => {
+ {/* + * Deliberately the first persistent, all-pages course banner: it lives + * here (rather than a single page, e.g. CourseSuspendedAlert on + * CourseShow) because the flag is on the layout payload, which is the + * only data available on every course page. + * + * Gated on `isPreview`, not `isPreviewRestricted`: "you are in the + * sandbox, nothing here is real" is a fact about the course and is + * equally worth saying to a system administrator — who also needs the + * Reset submission action it hosts. Only the LOCK is per-viewer, which + * is why the breadcrumbs above de-link on the narrower flag. + * + * The banner narrows itself further to submission pages only (see + * PreviewCourseBanner) — this gate is just "are we in the sandbox". + */} + {data.isPreview && } +
diff --git a/client/app/bundles/course/container/PreviewCourseBanner.tsx b/client/app/bundles/course/container/PreviewCourseBanner.tsx new file mode 100644 index 00000000000..9847712c904 --- /dev/null +++ b/client/app/bundles/course/container/PreviewCourseBanner.tsx @@ -0,0 +1,54 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import ResetSubmissionButton from 'course/marketplace/components/ResetSubmissionButton'; +import { getAssessmentId, getSubmissionId } from 'lib/helpers/url-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + header: { + id: 'course.courses.PreviewCourseBanner.header', + defaultMessage: + 'You are in the Assessment Marketplace preview sandbox. Answers, submissions and grades stay here and never reach a real course.', + }, +}); + +// Mounted by CourseContainer on every page of the marketplace preview sandbox course, but only +// speaks on a submission page. The promise it makes is about work a previewer produces, so it is +// only worth making where they are producing some; elsewhere in the sandbox there is nothing to +// reassure anyone about, and the Reset submission action it hosts has nothing to act on. A +// restricted previewer never sees the difference: PreviewLaunchService lands them straight on the +// submission, and the de-linked breadcrumbs and sidebar keep them there. A system administrator +// does — they curate the container from inside it (version snapshots, the authoring copies +// RestoreAuthoringJob rebuilds), and a sandbox notice on every page of that is pure nag. +// +// Read straight off the URL (mirroring `BaseCourseAPI#courseId`) rather than route params: this +// banner is mounted alongside ``, not inside it, and preview pages are ordinary +// assessment/submission routes with no preview-only param to key off. CourseContainer subscribes to +// `useLocation`, so it re-renders this on every in-app navigation. +const PreviewCourseBanner: FC = () => { + const { t } = useTranslation(); + + const assessmentId = getAssessmentId(); + const submissionId = getSubmissionId(); + + if (!assessmentId || !submissionId) return null; + + return ( + + } + severity="info" + sx={{ alignItems: 'center' }} + > + {t(translations.header)} + + ); +}; + +export default PreviewCourseBanner; diff --git a/client/app/bundles/course/container/__tests__/PreviewCourseBanner.test.tsx b/client/app/bundles/course/container/__tests__/PreviewCourseBanner.test.tsx new file mode 100644 index 00000000000..dbfe2de7ec0 --- /dev/null +++ b/client/app/bundles/course/container/__tests__/PreviewCourseBanner.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from 'test-utils'; + +import PreviewCourseBanner from '../PreviewCourseBanner'; + +const SANDBOX_NOTICE = /marketplace preview sandbox/i; + +const goTo = (path: string): void => window.history.pushState({}, '', path); + +beforeEach(() => { + // Reset to the bare course-home URL `setup.js` establishes globally, so each test starts from + // "no submission id in the route" unless it navigates somewhere else itself. + goTo(`/courses/${global.courseId}`); +}); + +describe('PreviewCourseBanner', () => { + it('renders the marketplace preview sandbox notice inside a submission', async () => { + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); + render(); + expect(await screen.findByText(SANDBOX_NOTICE)).toBeVisible(); + }); + + // Pinned separately from the label above: the notice used to claim the sandbox was "read-only" on + // the one page where a previewer types answers and submits them, so what it promises matters more + // than that it appeared. + it('promises that the work stays in the sandbox', async () => { + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); + render(); + + expect(await screen.findByText(/never reach a real course/i)).toBeVisible(); + }); + + it('renders nothing on the course home page', () => { + render(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); + + it('renders nothing on the assessments index', () => { + goTo(`/courses/${global.courseId}/assessments`); + render(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); + + it("renders nothing on an assessment's own show page", () => { + goTo(`/courses/${global.courseId}/assessments/5`); + render(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); +}); + +// Mirrors the exact conditional mounted in CourseContainer.tsx: +// `{data.isPreview && }`. CourseContainer itself pulls +// its data from a react-router loader, so it isn't worth a full-container +// mock scaffold just to prove this one boolean gate. +// +// The gate is `isPreview`, not the narrower `isPreviewRestricted` that de-links +// the breadcrumbs and sidebar: the banner states a fact about the course, so a +// system administrator sees it too (see Course::CoursesController#sidebar) — +// but only on the pages where it is true of what they are looking at, which the +// banner itself decides (see the suite above). +const Gated = ({ isPreview }: { isPreview?: boolean }): JSX.Element => ( + <> +
+ {isPreview && } + +); + +describe('the isPreview gate mounted in CourseContainer', () => { + beforeEach(() => { + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); + }); + + it('shows the banner when isPreview is true', async () => { + render(); + expect(await screen.findByText(SANDBOX_NOTICE)).toBeVisible(); + }); + + it('does not show the banner when isPreview is false', async () => { + render(); + expect(await screen.findByTestId('marker')).toBeInTheDocument(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); + + it('does not show the banner when isPreview is undefined', async () => { + render(); + expect(await screen.findByTestId('marker')).toBeInTheDocument(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/marketplace/components/ResetSubmissionButton.tsx b/client/app/bundles/course/marketplace/components/ResetSubmissionButton.tsx new file mode 100644 index 00000000000..8c44488a157 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/ResetSubmissionButton.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { RestartAlt } from '@mui/icons-material'; +import { Button } from '@mui/material'; + +import { fetchSubmission } from 'course/assessment/submission/actions'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { resetPreviewSubmission } from '../operations'; +import translations from '../translations'; + +interface ResetSubmissionButtonProps { + assessmentId: string; + submissionId: string; +} + +// Rendered inside PreviewCourseBanner, which only mounts on a submission page inside the marketplace +// preview sandbox course and reads both ids off the URL there — so there is always something to +// reset by the time this renders. +const ResetSubmissionButton = ( + props: ResetSubmissionButtonProps, +): JSX.Element => { + const { assessmentId, submissionId } = props; + + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const confirm = async (): Promise => { + setSubmitting(true); + try { + await resetPreviewSubmission(Number(assessmentId)); + toast.success(t(translations.resetSubmissionSuccess)); + setOpen(false); + + // The reset submission keeps its id and is cleared back to blank server-side; stay on this + // page and show that blank state in place rather than navigating away. Re-dispatch the same + // fetch the submission edit page's `componentDidMount` uses + // (`course/assessment/submission/actions#fetchSubmission`) to re-hydrate it. This button is + // mounted in the persistent course banner, outside the submission edit page's component + // tree (siblings under `CourseContainer`, see `client/app/bundles/course/container/ + // CourseContainer.tsx`) — but the whole app shares a single Redux store (see + // `lib/components/wrappers/StoreProvider`), so dispatching here still reaches the exact + // `assessments.submission` slice the submission edit page reads via `connect`, and it + // re-renders with the fresh, blank data. + dispatch(fetchSubmission(submissionId)); + } catch { + toast.error(t(translations.resetSubmissionFailed)); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor="error" + primaryLabel={t(translations.resetSubmission)} + title={t(translations.resetSubmissionConfirmTitle)} + > + {t(translations.resetSubmissionConfirmBody)} + + + ); +}; + +export default ResetSubmissionButton; diff --git a/client/app/bundles/course/marketplace/components/__test__/ResetSubmissionButton.test.tsx b/client/app/bundles/course/marketplace/components/__test__/ResetSubmissionButton.test.tsx new file mode 100644 index 00000000000..0a46a2223e5 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/ResetSubmissionButton.test.tsx @@ -0,0 +1,96 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; + +import CourseAPI from 'api/course'; +import { fetchSubmission } from 'course/assessment/submission/actions'; +import toast from 'lib/hooks/toast'; + +import ResetSubmissionButton from '../ResetSubmissionButton'; + +// The failure/success toasts are asserted directly (not rendered), so a plain jest.fn() mock is +// enough — mirrors ListingPreview's test. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + +// Mirrors the pattern in `SubmissionEditIndex/components/button/__test__/PublishButton.test.tsx`: +// the button only needs to dispatch the action, not run its real thunk body (which would hit +// `CourseAPI.assessment.submissions.edit` and pull in the whole submission reducer stack). +jest.mock('course/assessment/submission/actions', () => ({ + fetchSubmission: jest.fn(() => (): Promise => Promise.resolve()), +})); + +const mockFetchSubmission = fetchSubmission as jest.Mock; + +const RESET_SUBMISSION = 'Reset submission'; + +const goTo = (path: string): void => window.history.pushState({}, '', path); + +const renderButton = (): ReturnType => + render(); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => { + mock.reset(); + jest.clearAllMocks(); + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); +}); + +it('renders the button', async () => { + renderButton(); + expect( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ).toBeVisible(); +}); + +it('asks for confirmation before resetting', async () => { + renderButton(); + + fireEvent.click( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/clears all your answers/)).toBeVisible(); +}); + +it('clears the submission, toasts success, does not navigate, and re-fetches the same submission in place', async () => { + const url = `/courses/${global.courseId}/assessments/5/preview_submission`; + mock.onPatch(url).reply(204); + + renderButton(); + + fireEvent.click( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ); + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: RESET_SUBMISSION }), + ); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(toast.success).toHaveBeenCalled(); + + // Still on the same submission edit page (no `navigate` call anywhere in this component + // anymore), and the now-blank submission is re-fetched in place. + expect(window.location.pathname).toBe( + `/courses/${global.courseId}/assessments/5/submissions/9/edit`, + ); + expect(mockFetchSubmission).toHaveBeenCalledWith('9'); +}); + +it('toasts an error and does not re-fetch when the reset fails', async () => { + const url = `/courses/${global.courseId}/assessments/5/preview_submission`; + mock.onPatch(url).reply(404); + + renderButton(); + + fireEvent.click( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ); + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: RESET_SUBMISSION }), + ); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(toast.error).toHaveBeenCalled(); + expect(mockFetchSubmission).not.toHaveBeenCalled(); +}); From 05bbfadc48d5511f4106bec8ac7b4c237a4f96df Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:36:40 +0800 Subject: [PATCH 22/28] feat(marketplace): poll auto-marking and refresh the preview in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A previewer has no grader colleague to refresh the page for them, so finalising a preview submission left them looking at an unmarked attempt with no indication anything was happening. The finalising request now hands back the auto-grading job it just enqueued — absent outside a preview course, and absent on any request that did not itself finalise — and the banner polls it, refetching the submission when it lands. It reads a 404 during polling as a purged sandbox and says so, rather than reporting a generic failure. --- .../submissions/_submission.json.jbuilder | 8 + .../actions/__test__/finalise.test.js | 81 ++++++ .../assessment/submission/actions/index.js | 11 + .../PreviewAutogradingBanner.tsx | 159 ++++++++++ .../PreviewAutogradingBanner.test.tsx | 274 ++++++++++++++++++ .../pages/SubmissionEditIndex/index.jsx | 2 + .../__test__/previewAutograding.test.ts | 68 +++++ .../assessment/submission/reducers/index.js | 2 + .../reducers/previewAutograding/index.ts | 50 ++++ .../selectors/previewAutograding.ts | 9 + .../assessment/submission/translations.ts | 20 ++ .../course/assessment/submission/types.ts | 5 + .../submissions_preview_autograding_spec.rb | 68 +++++ 13 files changed, 757 insertions(+) create mode 100644 client/app/bundles/course/assessment/submission/actions/__test__/finalise.test.js create mode 100644 client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAutogradingBanner.tsx create mode 100644 client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAutogradingBanner.test.tsx create mode 100644 client/app/bundles/course/assessment/submission/reducers/__test__/previewAutograding.test.ts create mode 100644 client/app/bundles/course/assessment/submission/reducers/previewAutograding/index.ts create mode 100644 client/app/bundles/course/assessment/submission/selectors/previewAutograding.ts create mode 100644 spec/controllers/course/assessment/submission/submissions_preview_autograding_spec.rb diff --git a/app/views/course/assessment/submission/submissions/_submission.json.jbuilder b/app/views/course/assessment/submission/submissions/_submission.json.jbuilder index b516f743940..020af820536 100644 --- a/app/views/course/assessment/submission/submissions/_submission.json.jbuilder +++ b/app/views/course/assessment/submission/submissions/_submission.json.jbuilder @@ -56,4 +56,12 @@ json.submission do json.basePoints assessment.base_exp json.bonusPoints assessment.time_bonus_exp json.pointsAwarded submission.current_points_awarded + + # Marketplace preview sandbox only: hand back the auto-grading job this very request enqueued + # (Course::Assessment::Submission#auto_grading_job) so the preview page can poll it and show the + # marks in place — a previewer has no grader colleague to refresh for them. Absent outside a + # preview course, and absent on any request that did not itself finalise the submission. + if current_course.preview? && submission.auto_grading_job + json.autoGradingJobUrl job_path(submission.auto_grading_job.job) + end end diff --git a/client/app/bundles/course/assessment/submission/actions/__test__/finalise.test.js b/client/app/bundles/course/assessment/submission/actions/__test__/finalise.test.js new file mode 100644 index 00000000000..2f90e76efa2 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/actions/__test__/finalise.test.js @@ -0,0 +1,81 @@ +import CourseAPI from 'api/course'; + +import { previewAutogradingStarted } from '../../reducers/previewAutograding'; +import { finalise } from '../index'; + +jest.mock('api/course'); + +// Minimal stand-in for the redux-thunk middleware: recursively invokes any +// dispatched thunk (function) and records every plain action object. Mirrors the +// helper in publish.test.js. +const runThunk = async (thunk) => { + const dispatched = []; + const dispatch = (action) => { + if (typeof action === 'function') return action(dispatch, () => ({})); + dispatched.push(action); + return action; + }; + await thunk(dispatch, () => ({})); + return dispatched; +}; + +const startedActions = (dispatched) => + dispatched.filter((action) => action.type === previewAutogradingStarted.type); + +describe('finalise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('starts polling when the response carries a preview auto-grading job url', async () => { + CourseAPI.assessment.submissions.update.mockResolvedValue({ + data: { + submission: { autoGradingJobUrl: '/jobs/9' }, + questions: [], + answers: [], + }, + }); + + const dispatched = await runThunk(finalise(1, [])); + + expect(startedActions(dispatched)).toEqual([ + previewAutogradingStarted({ jobUrl: '/jobs/9' }), + ]); + }); + + it('does not start polling outside a preview course, where the key is absent', async () => { + CourseAPI.assessment.submissions.update.mockResolvedValue({ + data: { submission: {}, questions: [], answers: [] }, + }); + + const dispatched = await runThunk(finalise(1, [])); + + expect(startedActions(dispatched)).toEqual([]); + }); + + it('does not start polling when finalising fails', async () => { + CourseAPI.assessment.submissions.update.mockRejectedValue( + new Error('network error'), + ); + + const dispatched = await runThunk(finalise(1, [])); + + expect(startedActions(dispatched)).toEqual([]); + }); + + it('still dispatches FINALISE_SUCCESS alongside the polling action', async () => { + CourseAPI.assessment.submissions.update.mockResolvedValue({ + data: { + submission: { autoGradingJobUrl: '/jobs/9' }, + questions: [], + answers: [], + }, + }); + + const dispatched = await runThunk(finalise(1, [])); + + expect( + dispatched.some((action) => action.type === 'FINALISE_SUCCESS'), + ).toBe(true); + }); +}); diff --git a/client/app/bundles/course/assessment/submission/actions/index.js b/client/app/bundles/course/assessment/submission/actions/index.js index 839411af1a0..a44ba370833 100644 --- a/client/app/bundles/course/assessment/submission/actions/index.js +++ b/client/app/bundles/course/assessment/submission/actions/index.js @@ -10,6 +10,7 @@ import { } from '../reducers/answerFlags'; import { historyActions } from '../reducers/history'; import { initiateLiveFeedbackChatPerQuestion } from '../reducers/liveFeedbackChats'; +import { previewAutogradingStarted } from '../reducers/previewAutograding'; import { scribingActions } from '../reducers/scribing'; import translations from '../translations'; @@ -148,6 +149,16 @@ export function finalise(submissionId, rawAnswers) { window.location = data.newSessionUrl; } dispatch({ type: actionTypes.FINALISE_SUCCESS, payload: data }); + // Marketplace preview sandbox only: the backend hands back the auto-grading job it just + // enqueued, so PreviewAutogradingBanner can poll it and refresh the marks in place. The key + // is absent everywhere else, which is what keeps this inert in real courses. + if (data.submission?.autoGradingJobUrl) { + dispatch( + previewAutogradingStarted({ + jobUrl: data.submission.autoGradingJobUrl, + }), + ); + } dispatch(setNotification(translations.updateSuccess)); }) .catch((error) => { diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAutogradingBanner.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAutogradingBanner.tsx new file mode 100644 index 00000000000..1652de7955a --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAutogradingBanner.tsx @@ -0,0 +1,159 @@ +import { FC, useEffect } from 'react'; +import { Alert, Typography } from '@mui/material'; + +import { fetchSubmission } from 'course/assessment/submission/actions'; +import { + previewAutogradingFailed, + previewAutogradingSandboxGone, + previewAutogradingSettled, +} from 'course/assessment/submission/reducers/previewAutograding'; +import { getPreviewAutograding } from 'course/assessment/submission/selectors/previewAutograding'; +import translations from 'course/assessment/submission/translations'; +import { useCourseContext } from 'course/container/CourseLoader'; +import { setNotification } from 'lib/actions'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { pollJobRequest } from 'lib/helpers/jobHelpers'; +import { getSubmissionId } from 'lib/helpers/url-helpers'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 60000; + +/** + * Marketplace preview sandbox only. A previewer finalises their own attempt with no grader + * colleague to refresh the page for them, so this banner polls the auto-grading job that + * `finalise` handed back and refetches the submission in place once it lands. + * + * The interval is owned by this component's own effect rather than the page's, both because + * SubmissionEditIndex is a class component and because unmounting the banner then guarantees the + * teardown — `pollJob`'s fire-and-forget default export orphans pollers across navigation. + */ +const PreviewAutogradingBanner: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + // Read defensively (optional chaining) — this page also mounts in test trees that may not sit + // under CourseContainer's outlet. + const isPreview = useCourseContext()?.isPreview; + const { jobUrl, status } = useAppSelector(getPreviewAutograding); + const submissionId = getSubmissionId(); + + useEffect(() => { + if (!isPreview || status !== 'polling' || !jobUrl) return undefined; + + const startedAt = Date.now(); + let cancelled = false; + let inFlight = false; + + // A purge can destroy this attempt's assessment and cascade its submission mid-session. The + // `jobs` row survives that, so the poll still succeeds — the absence only shows up as a 404 on + // the refetch. A 403 is something else entirely (cross-previewer denial) and must not land here. + const refetchAndDetectPurge = async (): Promise => { + let purged = false; + + await dispatch( + fetchSubmission( + submissionId, + undefined, + (error?: { response?: { status?: number } }) => { + if (error?.response?.status === 404) purged = true; + }, + ), + ); + + return purged; + }; + + const poller = setInterval(async () => { + // The deadline is checked BEFORE the `inFlight` guard, and that order is load-bearing: the jobs + // endpoint sets no axios timeout, so a hung request would otherwise pin `inFlight` true forever + // and every later tick would short-circuit before ever reaching this check — the 60s ceiling + // would never fire and the banner would spin indefinitely. + if (Date.now() - startedAt > POLL_TIMEOUT_MS) { + clearInterval(poller); + dispatch(previewAutogradingFailed()); + // Safe even with a request still in flight: `previewAutogradingFailed` flips `status` off + // 'polling', and `status` is in this effect's dependency array, so React runs the cleanup + // below (setting `cancelled = true`) on this very closure before the hung request's + // completion callback can be serviced. That is what stops a late resolution from dispatching + // a stray `previewAutogradingSettled` or success toast. If you ever remove `status` from the + // dependency array, that guarantee disappears. + return; + } + + // Deliberately does NOT check `cancelled` here. Stopping future ticks is `clearInterval`'s job + // in the cleanup below; duplicating it here would make the two redundant, and would silently + // render the `stops polling once the page unmounts` test unable to observe a leaked timer. + // `cancelled` is load-bearing further down, after the `await` and in the `catch`, where its + // real job is to stop an already-in-flight poll from dispatching into an unmounted tree. + // Do not "helpfully" add `cancelled ||` back. + if (inFlight) return; + + inFlight = true; + + try { + const response = await pollJobRequest(jobUrl); + if (cancelled) return; + + if (response.status === 'completed') { + clearInterval(poller); + // Decide what to say only AFTER the refetch resolves. Announcing success up front made a + // purged sandbox flash "evaluated" and then contradict itself with "no longer available". + const purged = await refetchAndDetectPurge(); + if (cancelled) return; + + if (purged) { + dispatch(previewAutogradingSandboxGone()); + } else { + dispatch(previewAutogradingSettled()); + dispatch(setNotification(translations.autogradeSubmissionSuccess)); + } + } else if (response.status === 'errored') { + clearInterval(poller); + // The likeliest purge path: the assessment was destroyed before the job ran, so the job + // died on ActiveJob deserialization instead of completing. The refetch both surfaces any + // partial grading and tells us whether the sandbox is gone. + const purged = await refetchAndDetectPurge(); + if (cancelled) return; + + dispatch( + purged + ? previewAutogradingSandboxGone() + : previewAutogradingFailed(), + ); + } + } catch { + if (!cancelled) { + clearInterval(poller); + dispatch(previewAutogradingFailed()); + } + } finally { + inFlight = false; + } + }, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + clearInterval(poller); + }; + }, [isPreview, status, jobUrl, submissionId, dispatch]); + + if (!isPreview || status === 'idle') return null; + + const polling = status === 'polling'; + + let message = translations.previewAutogradingStalled; + if (polling) message = translations.previewAutogradingInProgress; + if (status === 'gone') message = translations.previewAutogradingSandboxGone; + + return ( + : undefined} + severity={polling ? 'info' : 'warning'} + > + {t(message)} + + ); +}; + +export default PreviewAutogradingBanner; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAutogradingBanner.test.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAutogradingBanner.test.tsx new file mode 100644 index 00000000000..d3e5c63c2b4 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAutogradingBanner.test.tsx @@ -0,0 +1,274 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { render, waitFor } from 'test-utils'; + +import GlobalAPI from 'api'; +import { fetchSubmission } from 'course/assessment/submission/actions'; +import { useCourseContext } from 'course/container/CourseLoader'; +import { setNotification } from 'lib/actions'; + +import PreviewAutogradingBanner from '../PreviewAutogradingBanner'; + +jest.mock('course/assessment/submission/actions', () => ({ + fetchSubmission: jest.fn(() => (): Promise => Promise.resolve()), +})); + +jest.mock('course/container/CourseLoader', () => ({ + useCourseContext: jest.fn(), +})); + +jest.mock('lib/actions', () => ({ + setNotification: jest.fn(() => (): void => {}), +})); + +jest.mock('lib/helpers/url-helpers', () => ({ + ...jest.requireActual('lib/helpers/url-helpers'), + getSubmissionId: (): string => '42', +})); + +// The banner polls the *jobs* endpoint, which lives on its own axios client. +const jobsMock = createMockAdapter(GlobalAPI.jobs.client); + +const mockFetchSubmission = fetchSubmission as jest.Mock; +const mockUseCourseContext = useCourseContext as jest.Mock; +const mockSetNotification = setNotification as jest.Mock; + +const stateWith = (previewAutograding: object): object => ({ + assessments: { submission: { previewAutograding } }, +}); + +const POLLING = { jobUrl: '/jobs/9', status: 'polling' }; + +// The banner polls every 2s, which is longer than waitFor's 1s default — every wait here needs an +// explicit longer timeout, and every test needs a raised jest timeout. +const POLL_WAIT = { timeout: 8000 }; + +const settle = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); + +beforeEach(() => { + jobsMock.reset(); + jest.clearAllMocks(); + mockFetchSubmission.mockImplementation( + () => (): Promise => Promise.resolve(), + ); + mockUseCourseContext.mockReturnValue({ isPreview: true }); +}); + +describe('PreviewAutogradingBanner', () => { + it('renders nothing when no auto-grading is in flight', async () => { + const page = render(, { + state: stateWith({ jobUrl: null, status: 'idle' }), + }); + + await settle(3000); + + expect(page.queryByText(/Auto-marking/)).not.toBeInTheDocument(); + expect(jobsMock.history.get).toHaveLength(0); + }, 10000); + + it('tells the previewer that auto-marking is running while the job is pending', async () => { + jobsMock.onGet('/jobs/9').reply(200, { status: 'submitted' }); + + const page = render(, { + state: stateWith(POLLING), + }); + + expect(await page.findByText(/Auto-marking is running/)).toBeVisible(); + await waitFor( + () => expect(jobsMock.history.get.length).toBeGreaterThan(0), + POLL_WAIT, + ); + expect(mockFetchSubmission).not.toHaveBeenCalled(); + }, 15000); + + it('refetches the submission and clears itself when the job completes', async () => { + jobsMock.onGet('/jobs/9').reply(200, { status: 'completed' }); + + const page = render(, { + state: stateWith(POLLING), + }); + + await waitFor( + () => + expect(mockFetchSubmission).toHaveBeenCalledWith( + '42', + undefined, + expect.any(Function), + ), + POLL_WAIT, + ); + await waitFor( + () => + expect( + page.queryByText(/Auto-marking is running/), + ).not.toBeInTheDocument(), + POLL_WAIT, + ); + expect(mockSetNotification).toHaveBeenCalled(); + }, 15000); + + it('tells the previewer to refresh when the job errors', async () => { + jobsMock + .onGet('/jobs/9') + .reply(200, { status: 'errored', message: 'boom' }); + + const page = render(, { + state: stateWith(POLLING), + }); + + expect( + await page.findByText(/Auto-marking did not finish/, {}, POLL_WAIT), + ).toBeVisible(); + expect(mockFetchSubmission).toHaveBeenCalledWith( + '42', + undefined, + expect.any(Function), + ); + }, 15000); + + it('tells the previewer to refresh when the job request itself fails', async () => { + jobsMock.onGet('/jobs/9').networkError(); + + const page = render(, { + state: stateWith(POLLING), + }); + + expect( + await page.findByText(/Auto-marking did not finish/, {}, POLL_WAIT), + ).toBeVisible(); + }, 15000); + + it('keeps showing the failure notice without polling further', async () => { + const page = render(, { + state: stateWith({ jobUrl: null, status: 'failed' }), + }); + + expect(await page.findByText(/Auto-marking did not finish/)).toBeVisible(); + + await settle(3000); + + expect(jobsMock.history.get).toHaveLength(0); + }, 10000); + + it('renders nothing and never polls outside a preview course', async () => { + mockUseCourseContext.mockReturnValue({ isPreview: false }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'submitted' }); + + const page = render(, { + state: stateWith(POLLING), + }); + + await settle(3000); + + expect(page.queryByText(/Auto-marking/)).not.toBeInTheDocument(); + expect(jobsMock.history.get).toHaveLength(0); + }, 10000); + + it('renders nothing when mounted outside CourseContainer entirely', async () => { + mockUseCourseContext.mockReturnValue(undefined); + jobsMock.onGet('/jobs/9').reply(200, { status: 'submitted' }); + + const page = render(, { + state: stateWith(POLLING), + }); + + await settle(3000); + + expect(page.queryByText(/Auto-marking/)).not.toBeInTheDocument(); + expect(jobsMock.history.get).toHaveLength(0); + }, 10000); + + // Regression guard: `pollJob`'s default export orphans pollers across navigation (see its own + // docstring), and this feature has already shipped that bug once as stray `/assessments/null` + // requests. Deleting the effect's cleanup must fail this example. + it('stops polling once the page unmounts', async () => { + jobsMock.onGet('/jobs/9').reply(200, { status: 'submitted' }); + + const page = render(, { + state: stateWith(POLLING), + }); + + await waitFor( + () => expect(jobsMock.history.get.length).toBeGreaterThan(0), + POLL_WAIT, + ); + + page.unmount(); + const callsAtUnmount = jobsMock.history.get.length; + + await settle(5000); + + expect(jobsMock.history.get).toHaveLength(callsAtUnmount); + }, 20000); + + // A purge destroys the snapshot assessment and cascades its submission. The jobs row survives, so + // the poll succeeds and only the refetch 404s. + it('says the preview is gone rather than telling the previewer to refresh, when the submission was purged', async () => { + jobsMock.onGet('/jobs/9').reply(200, { status: 'completed' }); + mockFetchSubmission.mockImplementation( + (_id: string, _onSession: unknown, onError?: (e: unknown) => void) => + (): Promise => { + onError?.({ response: { status: 404 } }); + return Promise.resolve(); + }, + ); + + const page = render(, { + state: stateWith(POLLING), + }); + + expect( + await page.findByText(/no longer available/, {}, POLL_WAIT), + ).toBeVisible(); + expect(page.queryByText(/Refresh this page/)).not.toBeInTheDocument(); + expect(mockSetNotification).not.toHaveBeenCalled(); + }, 15000); + + // The likelier purge path: the assessment went away before the job ran, so the job errored on + // ActiveJob deserialization rather than completing. + it('promotes an errored job to gone when the submission was purged', async () => { + jobsMock + .onGet('/jobs/9') + .reply(200, { status: 'errored', message: 'boom' }); + mockFetchSubmission.mockImplementation( + (_id: string, _onSession: unknown, onError?: (e: unknown) => void) => + (): Promise => { + onError?.({ response: { status: 404 } }); + return Promise.resolve(); + }, + ); + + const page = render(, { + state: stateWith(POLLING), + }); + + expect( + await page.findByText(/no longer available/, {}, POLL_WAIT), + ).toBeVisible(); + }, 15000); + + // Guards the `=== 404` check specifically: any other failure is an ordinary failure, not a purge. + it('keeps the ordinary failure notice when the refetch fails for a non-404 reason', async () => { + jobsMock + .onGet('/jobs/9') + .reply(200, { status: 'errored', message: 'boom' }); + mockFetchSubmission.mockImplementation( + (_id: string, _onSession: unknown, onError?: (e: unknown) => void) => + (): Promise => { + onError?.({ response: { status: 500 } }); + return Promise.resolve(); + }, + ); + + const page = render(, { + state: stateWith(POLLING), + }); + + expect( + await page.findByText(/Auto-marking did not finish/, {}, POLL_WAIT), + ).toBeVisible(); + expect(page.queryByText(/no longer available/)).not.toBeInTheDocument(); + }, 15000); +}); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx index 1ec74134964..b26bc53812e 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx @@ -40,6 +40,7 @@ import { import translations from '../../translations'; import BlockedSubmission from './BlockedSubmission'; +import PreviewAutogradingBanner from './PreviewAutogradingBanner'; import SubmissionEmptyForm from './SubmissionEmptyForm'; import SubmissionForm from './SubmissionForm'; import TimeLimitBanner from './TimeLimitBanner'; @@ -174,6 +175,7 @@ class VisibleSubmissionEditIndex extends Component { return ( {this.renderTimeLimitBanner()} + {this.renderAssessment()} {isBlockedInStudentView ? (
diff --git a/client/app/bundles/course/assessment/submission/reducers/__test__/previewAutograding.test.ts b/client/app/bundles/course/assessment/submission/reducers/__test__/previewAutograding.test.ts new file mode 100644 index 00000000000..3f483b28297 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/reducers/__test__/previewAutograding.test.ts @@ -0,0 +1,68 @@ +import reducer, { + previewAutogradingFailed, + previewAutogradingSandboxGone, + previewAutogradingSettled, + previewAutogradingStarted, +} from '../previewAutograding'; + +describe('previewAutograding reducer', () => { + it('starts idle with no job', () => { + expect(reducer(undefined, { type: '@@INIT' })).toEqual({ + jobUrl: null, + status: 'idle', + }); + }); + + it('records the job url and starts polling', () => { + const state = reducer( + undefined, + previewAutogradingStarted({ jobUrl: '/jobs/9' }), + ); + + expect(state).toEqual({ jobUrl: '/jobs/9', status: 'polling' }); + }); + + it('clears itself back to idle when the job settles', () => { + const polling = reducer( + undefined, + previewAutogradingStarted({ jobUrl: '/jobs/9' }), + ); + + expect(reducer(polling, previewAutogradingSettled())).toEqual({ + jobUrl: null, + status: 'idle', + }); + }); + + it('drops the job url but remembers the failure so the banner can persist', () => { + const polling = reducer( + undefined, + previewAutogradingStarted({ jobUrl: '/jobs/9' }), + ); + + expect(reducer(polling, previewAutogradingFailed())).toEqual({ + jobUrl: null, + status: 'failed', + }); + }); + + it('lets a fresh finalise restart polling after a previous failure', () => { + const failed = reducer(undefined, previewAutogradingFailed()); + + expect( + reducer(failed, previewAutogradingStarted({ jobUrl: '/jobs/10' })), + ).toEqual({ jobUrl: '/jobs/10', status: 'polling' }); + }); + + it('marks the sandbox gone, dropping the job url', () => { + const polling = reducer( + undefined, + previewAutogradingStarted({ jobUrl: '/jobs/9' }), + ); + + expect(reducer(polling, previewAutogradingSandboxGone())).toEqual({ + jobUrl: null, + status: 'gone', + }); + }); +}); diff --git a/client/app/bundles/course/assessment/submission/reducers/index.js b/client/app/bundles/course/assessment/submission/reducers/index.js index 0e9b7d554fa..2cd26e801eb 100644 --- a/client/app/bundles/course/assessment/submission/reducers/index.js +++ b/client/app/bundles/course/assessment/submission/reducers/index.js @@ -13,6 +13,7 @@ import gradingResults from './gradingResults'; import history from './history'; import liveFeedbackChats from './liveFeedbackChats'; import posts from './posts'; +import previewAutograding from './previewAutograding'; import questions from './questions'; import questionsFlags from './questionsFlags'; import recorder from './recorder'; @@ -35,6 +36,7 @@ const submissionReducer = combineReducers({ gradingResults, liveFeedbackChats, posts, + previewAutograding, questions, questionsFlags, submission, diff --git a/client/app/bundles/course/assessment/submission/reducers/previewAutograding/index.ts b/client/app/bundles/course/assessment/submission/reducers/previewAutograding/index.ts new file mode 100644 index 00000000000..6e0ce773095 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/reducers/previewAutograding/index.ts @@ -0,0 +1,50 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { PreviewAutogradingState } from '../../types'; + +const initialState: PreviewAutogradingState = { + jobUrl: null, + status: 'idle', +}; + +export const previewAutogradingSlice = createSlice({ + name: 'previewAutograding', + initialState, + reducers: { + previewAutogradingStarted: ( + state, + action: PayloadAction<{ jobUrl: string }>, + ) => { + state.jobUrl = action.payload.jobUrl; + state.status = 'polling'; + }, + // The job finished and the submission has been refetched; the banner has nothing left to say. + previewAutogradingSettled: (state) => { + state.jobUrl = null; + state.status = 'idle'; + }, + // The job errored or outlived the poll window. The url is dropped (nothing left to poll) but the + // status persists, so the banner can keep telling the previewer to refresh. + previewAutogradingFailed: (state) => { + state.jobUrl = null; + state.status = 'failed'; + }, + // The sandbox content this attempt lived in was purged mid-session: an admin permanently deleted + // an orphaned marketplace listing, which destroys its snapshot assessments and cascades their + // submissions. Deliberately distinct from `failed` — there is nothing left to refresh back into, + // so the banner must not tell the previewer to refresh. + previewAutogradingSandboxGone: (state) => { + state.jobUrl = null; + state.status = 'gone'; + }, + }, +}); + +export const { + previewAutogradingFailed, + previewAutogradingSandboxGone, + previewAutogradingSettled, + previewAutogradingStarted, +} = previewAutogradingSlice.actions; + +export default previewAutogradingSlice.reducer; diff --git a/client/app/bundles/course/assessment/submission/selectors/previewAutograding.ts b/client/app/bundles/course/assessment/submission/selectors/previewAutograding.ts new file mode 100644 index 00000000000..fb58ebd49a8 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/selectors/previewAutograding.ts @@ -0,0 +1,9 @@ +import { AppState } from 'store'; + +import { PreviewAutogradingState } from '../types'; + +export const getPreviewAutograding = ( + state: AppState, +): PreviewAutogradingState => { + return state.assessments.submission.previewAutograding; +}; diff --git a/client/app/bundles/course/assessment/submission/translations.ts b/client/app/bundles/course/assessment/submission/translations.ts index fd82e5b2afa..092f7a3c543 100644 --- a/client/app/bundles/course/assessment/submission/translations.ts +++ b/client/app/bundles/course/assessment/submission/translations.ts @@ -368,6 +368,26 @@ const translations = defineMessages({ id: 'course.assessment.submission.updateSuccess', defaultMessage: 'Submission updated successfully.', }, + previewPublishSuccess: { + id: 'course.assessment.submission.previewPublishSuccess', + defaultMessage: + 'Grade published. In a real course, the student would now be able to see this grade and feedback immediately.', + }, + previewAutogradingInProgress: { + id: 'course.assessment.submission.previewAutogradingInProgress', + defaultMessage: + 'Auto-marking is running. The results will appear here in a moment.', + }, + previewAutogradingStalled: { + id: 'course.assessment.submission.previewAutogradingStalled', + defaultMessage: + 'Auto-marking did not finish. Refresh this page to check for results.', + }, + previewAutogradingSandboxGone: { + id: 'course.assessment.submission.previewAutogradingSandboxGone', + defaultMessage: + 'This preview is no longer available. The assessment it was based on has been removed from the marketplace.', + }, updateIndividualSuccess: { id: 'course.assessment.submission.updateIndividualSuccess', defaultMessage: 'Submission for {errors} updated successfully', diff --git a/client/app/bundles/course/assessment/submission/types.ts b/client/app/bundles/course/assessment/submission/types.ts index e3de23fdefe..3fd1a4d3948 100644 --- a/client/app/bundles/course/assessment/submission/types.ts +++ b/client/app/bundles/course/assessment/submission/types.ts @@ -132,6 +132,11 @@ export interface SubmissionFlagsState { isUnsubmitting: boolean; } +export interface PreviewAutogradingState { + jobUrl: string | null; + status: 'idle' | 'polling' | 'failed' | 'gone'; +} + export interface QuestionFlag { isAutograding: boolean; isResetting: boolean; diff --git a/spec/controllers/course/assessment/submission/submissions_preview_autograding_spec.rb b/spec/controllers/course/assessment/submission/submissions_preview_autograding_spec.rb new file mode 100644 index 00000000000..2e799a1a295 --- /dev/null +++ b/spec/controllers/course/assessment/submission/submissions_preview_autograding_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +require 'rails_helper' + +# The marketplace preview sandbox needs the finalise response to carry the auto-grading job it just +# enqueued, so the preview page can poll it and show the marks without a manual refresh. The job is +# created by an `after_commit` hook that fires during the finalising `save` — i.e. before the +# controller renders — so it is available to the view; these examples pin that down, and pin down +# that the key leaks nowhere else. +# +# NOTE: deliberately a separate file from submissions_controller_spec.rb, which is being edited +# concurrently by the preview-sandbox-authorization work. +RSpec.describe Course::Assessment::Submission::SubmissionsController, type: :controller do + let(:instance) { create(:instance) } + + with_tenant(:instance) do + with_active_job_queue_adapter(:test) do + render_views + + let(:previewer) { create(:user) } + let(:assessment) { create(:assessment, :published, :with_mcq_question, course: course) } + let!(:course_user) { create(:course_manager, course: course, user: previewer) } + let!(:submission) do + create(:submission, :attempting, assessment: assessment, course: course, creator: previewer) + end + + before { controller_sign_in(controller, previewer) } + + def finalise! + patch :update, params: { + course_id: course, assessment_id: assessment, id: submission, + submission: { finalise: true }, format: :json + } + end + + context 'when the course is a marketplace preview sandbox' do + let(:course) { create(:course, preview: true) } + + it 'exposes the url of the auto-grading job this request enqueued' do + finalise! + + expect(response).to have_http_status(:ok) + + job_url = response.parsed_body['submission']['autoGradingJobUrl'] + expect(job_url).to match(/\A\/jobs\/[0-9a-f-]{36}\z/) + expect(TrackableJob::Job.find(job_url.split('/').last)).to be_present + end + + it 'does not expose the key on a plain edit, which enqueues nothing' do + get :edit, params: { course_id: course, assessment_id: assessment, id: submission, format: :json } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['submission']).not_to have_key('autoGradingJobUrl') + end + end + + context 'when the course is an ordinary course' do + let(:course) { create(:course) } + + it 'does not expose the key, even though a job was still enqueued' do + finalise! + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['submission']).not_to have_key('autoGradingJobUrl') + end + end + end + end +end From add945c750ec7c19913559afab5c7db10dcb32c0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:36:53 +0800 Subject: [PATCH 23/28] feat(marketplace): tailor the publish toast to the preview sandbox "Submission updated successfully" says nothing about what publishing a grade would actually do. In the sandbox the previewer is rehearsing the grader's side, so the toast names the consequence they came to see: the student would now be able to read this grade and feedback. --- .../actions/__test__/publish.test.js | 67 ++++++++++++++++ .../assessment/submission/actions/index.js | 10 ++- .../components/button/PublishButton.tsx | 11 ++- .../button/__test__/PublishButton.test.tsx | 78 +++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 client/app/bundles/course/assessment/submission/actions/__test__/publish.test.js create mode 100644 client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/__test__/PublishButton.test.tsx diff --git a/client/app/bundles/course/assessment/submission/actions/__test__/publish.test.js b/client/app/bundles/course/assessment/submission/actions/__test__/publish.test.js new file mode 100644 index 00000000000..e8c061eff95 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/actions/__test__/publish.test.js @@ -0,0 +1,67 @@ +import CourseAPI from 'api/course'; +import notificationActionTypes from 'lib/constants'; + +import translations from '../../translations'; +import { publish } from '../index'; + +jest.mock('api/course'); + +// Minimal stand-in for the redux-thunk middleware: recursively invokes any +// dispatched thunk (function) and records every plain action object. This lets +// the test observe the full dispatch sequence of `publish` (including its +// nested `setNotification` thunk) without mounting a real store/reducers. +const runThunk = async (thunk) => { + const dispatched = []; + const dispatch = (action) => { + if (typeof action === 'function') return action(dispatch, () => ({})); + dispatched.push(action); + return action; + }; + await thunk(dispatch, () => ({})); + return dispatched; +}; + +const okResponse = { + data: { submission: {}, questions: [], answers: [] }, +}; + +describe('publish', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows the generic update-success notification outside a preview course', async () => { + CourseAPI.assessment.submissions.update.mockResolvedValue(okResponse); + + const dispatched = await runThunk(publish(1, [], 0, false)); + + const notification = dispatched.find( + (action) => action.type === notificationActionTypes.SET_NOTIFICATION, + ); + expect(notification.message).toBe(translations.updateSuccess); + }); + + it('shows the preview-specific notification inside a preview course', async () => { + CourseAPI.assessment.submissions.update.mockResolvedValue(okResponse); + + const dispatched = await runThunk(publish(1, [], 0, true)); + + const notification = dispatched.find( + (action) => action.type === notificationActionTypes.SET_NOTIFICATION, + ); + expect(notification.message).toBe(translations.previewPublishSuccess); + }); + + it('does not show the preview-specific notification on failure', async () => { + CourseAPI.assessment.submissions.update.mockRejectedValue( + new Error('network error'), + ); + + const dispatched = await runThunk(publish(1, [], 0, true)); + + const notification = dispatched.find( + (action) => action.type === notificationActionTypes.SET_NOTIFICATION, + ); + expect(notification.message).toBe(translations.getPastAnswersFailure); + }); +}); diff --git a/client/app/bundles/course/assessment/submission/actions/index.js b/client/app/bundles/course/assessment/submission/actions/index.js index a44ba370833..a033b0cb3a8 100644 --- a/client/app/bundles/course/assessment/submission/actions/index.js +++ b/client/app/bundles/course/assessment/submission/actions/index.js @@ -241,7 +241,7 @@ export function unmark(submissionId) { }; } -export function publish(submissionId, grades, exp) { +export function publish(submissionId, grades, exp, isPreview) { const payload = { submission: { answers: grades, @@ -257,7 +257,13 @@ export function publish(submissionId, grades, exp) { .then((response) => response.data) .then((data) => { dispatch({ type: actionTypes.PUBLISH_SUCCESS, payload: data }); - dispatch(setNotification(translations.updateSuccess)); + dispatch( + setNotification( + isPreview + ? translations.previewPublishSuccess + : translations.updateSuccess, + ), + ); }) .catch((error) => { dispatch({ type: actionTypes.PUBLISH_FAILURE }); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/PublishButton.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/PublishButton.tsx index 9056c31be9d..f70aa70edbd 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/PublishButton.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/PublishButton.tsx @@ -11,6 +11,7 @@ import { import { getSubmissionFlags } from 'course/assessment/submission/selectors/submissionFlags'; import { getSubmission } from 'course/assessment/submission/selectors/submissions'; import translations from 'course/assessment/submission/translations'; +import { useCourseContext } from 'course/container/CourseLoader'; import { getSubmissionId } from 'lib/helpers/url-helpers'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -24,6 +25,9 @@ const PublishButton: FC = () => { const questionWithGrades = useAppSelector(getQuestionWithGrades); const submissionFlags = useAppSelector(getSubmissionFlags); const expPoints = useAppSelector(getExperiencePoints); + // Read defensively (optional chaining) — this button also mounts in test/other + // trees that may not sit under CourseContainer's outlet. + const isPreview = useCourseContext()?.isPreview; const { delayedGradePublication } = assessment; const { graderView, workflowState } = submission; @@ -39,7 +43,12 @@ const PublishButton: FC = () => { const handlePublish = (): void => { dispatch( - publish(submissionId, Object.values(questionWithGrades), expPoints), + publish( + submissionId, + Object.values(questionWithGrades), + expPoints, + isPreview, + ), ); }; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/__test__/PublishButton.test.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/__test__/PublishButton.test.tsx new file mode 100644 index 00000000000..e0c5fe3db98 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/__test__/PublishButton.test.tsx @@ -0,0 +1,78 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test-utils'; + +import { publish } from 'course/assessment/submission/actions'; +import { useCourseContext } from 'course/container/CourseLoader'; + +import PublishButton from '../PublishButton'; + +jest.mock('course/assessment/submission/actions', () => ({ + publish: jest.fn(() => (): Promise => Promise.resolve()), +})); + +jest.mock('course/container/CourseLoader', () => ({ + useCourseContext: jest.fn(), +})); + +jest.mock('lib/helpers/url-helpers', () => ({ + ...jest.requireActual('lib/helpers/url-helpers'), + getSubmissionId: (): string => '42', +})); + +const mockPublish = publish as jest.Mock; +const mockUseCourseContext = useCourseContext as jest.Mock; + +const buildState = (): object => ({ + assessments: { + submission: { + assessment: { delayedGradePublication: false }, + submission: { graderView: true, workflowState: 'submitted' }, + submissionFlags: { isSaving: false }, + grading: { questions: { 1: { grade: 10 } }, exp: 0 }, + }, + }, +}); + +describe('PublishButton', () => { + beforeEach(() => { + mockPublish.mockClear(); + }); + + it('threads isPreview through to publish() inside a preview course', async () => { + mockUseCourseContext.mockReturnValue({ isPreview: true }); + const user = userEvent.setup(); + + render(, { state: buildState() }); + + await user.click(await screen.findByRole('button')); + + expect(mockPublish).toHaveBeenCalledWith('42', [{ grade: 10 }], 0, true); + }); + + it('passes isPreview=false outside a preview course', async () => { + mockUseCourseContext.mockReturnValue({ isPreview: false }); + const user = userEvent.setup(); + + render(, { state: buildState() }); + + await user.click(await screen.findByRole('button')); + + expect(mockPublish).toHaveBeenCalledWith('42', [{ grade: 10 }], 0, false); + }); + + it('passes isPreview=undefined when rendered outside CourseContainer entirely', async () => { + mockUseCourseContext.mockReturnValue(undefined); + const user = userEvent.setup(); + + render(, { state: buildState() }); + + await user.click(await screen.findByRole('button')); + + expect(mockPublish).toHaveBeenCalledWith( + '42', + [{ grade: 10 }], + 0, + undefined, + ); + }); +}); From 54119b1a9d1e499b84ba537c2ec6d1f79ae654e7 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:37:10 +0800 Subject: [PATCH 24/28] fix(submission): send a missing submission to a usable not-found page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A submission url whose record does not exist rendered the page's normal shell with empty state, which reads as a broken page rather than a wrong address. The page's own load now redirects to the not-found page on a 404 — deliberately a separate thunk, since the preview banner refetches through `fetchSubmission` and reads the same 404 as a purged sandbox. Three things make that page fit once you arrive: - the redirect carries the address it came from and the page puts it back, so the viewer sees the url they asked for rather than `/404`, the way the route catch-all already behaves; - `/submissions/:id` with no `edit` used to match a parent route with children but no index, rendering an empty outlet inside the course shell for any id, real or invented. An index route redirects it to `edit`; - a restricted previewer gets no "go back home" link — `/` is the sandbox container, which the lock denies, so the link only led to a 403. --- client/app/bundles/common/ErrorPage.tsx | 47 ++++++-- .../common/__test__/ErrorPage.test.tsx | 82 +++++++++++++ .../actions/__test__/fetchSubmission.test.js | 114 ++++++++++++++++++ .../assessment/submission/actions/index.js | 29 ++++- .../pages/SubmissionEditIndex/index.jsx | 4 +- .../hooks/router/__test__/redirect.test.ts | 52 ++++++++ client/app/lib/hooks/router/redirect.tsx | 26 +++- .../assessmentSubmissionRoutes.test.tsx | 101 ++++++++++++++++ .../course/assessments/submissions.tsx | 7 +- client/app/types/home.ts | 4 + 10 files changed, 453 insertions(+), 13 deletions(-) create mode 100644 client/app/bundles/common/__test__/ErrorPage.test.tsx create mode 100644 client/app/bundles/course/assessment/submission/actions/__test__/fetchSubmission.test.js create mode 100644 client/app/lib/hooks/router/__test__/redirect.test.ts create mode 100644 client/app/routers/course/__tests__/assessmentSubmissionRoutes.test.tsx diff --git a/client/app/bundles/common/ErrorPage.tsx b/client/app/bundles/common/ErrorPage.tsx index e804ead6823..d051fb29962 100644 --- a/client/app/bundles/common/ErrorPage.tsx +++ b/client/app/bundles/common/ErrorPage.tsx @@ -18,9 +18,11 @@ import { Attributions, useSetAttributions, } from 'lib/components/wrappers/AttributionsProvider'; +import { useAppContext } from 'lib/containers/AppContainer'; import { getCourseIdFromString } from 'lib/helpers/url-helpers'; import { getForbiddenSourceURL, + getNotFoundSourceURL, getSuspendedSourceURL, } from 'lib/hooks/router/redirect'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; @@ -39,6 +41,11 @@ const translations = defineMessages({ defaultMessage: "Check if you've typed the correct address, try again later, or go back home.", }, + notFoundSubtitleWithoutHome: { + id: 'app.ErrorPage.notFoundSubtitleWithoutHome', + defaultMessage: + "Check if you've typed the correct address, or try again later.", + }, notFoundIllustrationAttribution: { id: 'app.ErrorPage.notFoundIllustrationAttribution', defaultMessage: @@ -135,6 +142,27 @@ const ErrorPage = (props: ErrorPageProps): JSX.Element => { const NotFoundPage = (): JSX.Element => { const { t } = useTranslation(); + // A marketplace previewer has nowhere to go back to: `/` resolves to the preview container, their + // only course, and the sandbox lock denies it — so the link would land them on a 403. The link is + // dropped rather than repointed, and it takes a second message rather than a conditional chunk: an + // ICU tag cannot be omitted from a rendered message, and a `` left without a handler renders + // the tag literally. `CourselessContainer` forwards the root payload to this outlet, which is the + // only place a courseless page can read the flag from. + const { isPreviewRestricted } = useAppContext(); + + // Most viewers reach this page because no route matched their URL, and the address bar already + // reads what they typed. The rest are redirected here from a route that did match but whose record + // turned out missing, and arrive carrying that address — put it back, so both look the same. + // + // Read off `window.location` rather than through a route loader as `ForbiddenPage` does. That page + // is only ever mounted at `/forbidden`; this one is also the catch-all's element, mounted in tests + // under a plain `MemoryRouter`, where `useLoaderData` throws. + const sourceURL = getNotFoundSourceURL(window.location.href); + + useEffectOnce(() => { + if (sourceURL) window.history.replaceState(null, '', sourceURL); + }); + return ( { ]} illustrationAlt="Not found illustration" illustrationSrc={notFoundIllustration} - subtitle={t(translations.notFoundSubtitle, { - home: (chunk) => ( - - {chunk} - - ), - })} + subtitle={ + isPreviewRestricted + ? t(translations.notFoundSubtitleWithoutHome) + : t(translations.notFoundSubtitle, { + home: (chunk) => ( + + {chunk} + + ), + }) + } + tip={sourceURL ?? undefined} title={t(translations.notFound)} /> ); diff --git a/client/app/bundles/common/__test__/ErrorPage.test.tsx b/client/app/bundles/common/__test__/ErrorPage.test.tsx new file mode 100644 index 00000000000..5508ff9a008 --- /dev/null +++ b/client/app/bundles/common/__test__/ErrorPage.test.tsx @@ -0,0 +1,82 @@ +import { render } from 'test-utils'; +import { HomeLayoutData } from 'types/home'; + +import ErrorPage from '../ErrorPage'; + +// `NotFoundPage` renders inside `CourselessContainer`'s outlet, which forwards the root payload as the +// outlet context `useAppContext()` reads. There is no outlet here, so the payload is supplied directly. +const mockAppContext: HomeLayoutData = { locale: 'en', timeZone: null }; + +jest.mock('lib/containers/AppContainer', () => ({ + ...jest.requireActual('lib/containers/AppContainer'), + useAppContext: (): HomeLayoutData => mockAppContext, +})); + +describe('NotFoundPage', () => { + beforeEach(() => { + delete mockAppContext.isPreviewRestricted; + window.history.replaceState(null, '', '/'); + }); + + it('offers a link home', async () => { + const page = render(); + + expect( + (await page.findByText('go back home')).closest('a'), + ).toHaveAttribute('href', '/'); + }); + + it('omits the link home for a restricted previewer', async () => { + mockAppContext.isPreviewRestricted = true; + + const page = render(); + + expect( + await page.findByText( + "Check if you've typed the correct address, or try again later.", + ), + ).toBeInTheDocument(); + expect(page.queryByText('go back home')).not.toBeInTheDocument(); + }); + + // A 404 raised after a route already matched arrives here by redirect, so the address bar reads + // `/404` rather than the page the viewer actually asked for. Putting it back is what makes this + // read like the catch-all's not-found page, which never leaves the address it was typed at. + describe('when redirected from a page whose record was missing', () => { + const sourceURL = '/courses/8/assessments/33/submissions/818/edit'; + + beforeEach(() => { + window.history.replaceState( + null, + '', + `/404?from=${encodeURIComponent(sourceURL)}`, + ); + }); + + it('restores the address it was redirected from', async () => { + const page = render(); + + await page.findByText("That location doesn't exist in this universe..."); + + expect(window.location.pathname + window.location.search).toBe(sourceURL); + }); + + it('names that address rather than /404', async () => { + const page = render(); + + expect(await page.findByText(sourceURL)).toBeInTheDocument(); + expect(page.queryByText('/404')).not.toBeInTheDocument(); + }); + }); + + // The catch-all reaches this page without a redirect, so there is nothing to restore and the + // address is already the right one to show. + it('leaves an address it was not redirected to alone', async () => { + window.history.replaceState(null, '', '/courses/8/nonsense'); + + const page = render(); + + expect(await page.findByText('/courses/8/nonsense')).toBeInTheDocument(); + expect(window.location.pathname).toBe('/courses/8/nonsense'); + }); +}); diff --git a/client/app/bundles/course/assessment/submission/actions/__test__/fetchSubmission.test.js b/client/app/bundles/course/assessment/submission/actions/__test__/fetchSubmission.test.js new file mode 100644 index 00000000000..94903d7c808 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/actions/__test__/fetchSubmission.test.js @@ -0,0 +1,114 @@ +import CourseAPI from 'api/course'; +import { redirectToNotFound } from 'lib/hooks/router/redirect'; + +import { fetchSubmission, loadSubmissionPage } from '../index'; + +jest.mock('api/course'); +jest.mock('lib/hooks/router/redirect', () => ({ + redirectToNotFound: jest.fn(), +})); + +// Minimal stand-in for the redux-thunk middleware: recursively invokes any +// dispatched thunk (function) and records every plain action object. Mirrors the +// helper in publish.test.js and finalise.test.js. +const runThunk = async (thunk) => { + const dispatched = []; + const dispatch = (action) => { + if (typeof action === 'function') return action(dispatch, () => ({})); + dispatched.push(action); + return action; + }; + await thunk(dispatch, () => ({})); + return dispatched; +}; + +describe('fetchSubmission', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('hands the axios error to onError when the fetch fails', async () => { + const error = { response: { status: 404 } }; + CourseAPI.assessment.submissions.edit.mockRejectedValue(error); + const onError = jest.fn(); + + await runThunk(fetchSubmission(42, undefined, onError)); + + expect(onError).toHaveBeenCalledWith(error); + }); + + it('does not call onError when the fetch succeeds', async () => { + CourseAPI.assessment.submissions.edit.mockResolvedValue({ + data: { + submission: { id: 42 }, + questions: [], + answers: [], + history: { questions: [] }, + }, + }); + const onError = jest.fn(); + + await runThunk(fetchSubmission(42, undefined, onError)); + + expect(onError).not.toHaveBeenCalled(); + }); + + it('still dispatches FETCH_SUBMISSION_FAILURE when onError is omitted', async () => { + CourseAPI.assessment.submissions.edit.mockRejectedValue({ + response: { status: 500 }, + }); + + const dispatched = await runThunk(fetchSubmission(42)); + + expect( + dispatched.some((action) => action.type === 'FETCH_SUBMISSION_FAILURE'), + ).toBe(true); + }); +}); + +describe('loadSubmissionPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('sends the viewer to the not-found page when the fetch 404s', async () => { + CourseAPI.assessment.submissions.edit.mockRejectedValue({ + response: { status: 404 }, + }); + + await runThunk(loadSubmissionPage(42)); + + expect(redirectToNotFound).toHaveBeenCalled(); + }); + + // Only a 404 means "no such submission under this assessment". This matters beyond tidiness: the + // marketplace preview banner reads the very same 404 as a purged sandbox and has its own message + // for it, which is why it refetches through `fetchSubmission` directly rather than through here. + it('stays on the page on any other failure', async () => { + CourseAPI.assessment.submissions.edit.mockRejectedValue({ + response: { status: 500 }, + }); + + const dispatched = await runThunk(loadSubmissionPage(42)); + + expect(redirectToNotFound).not.toHaveBeenCalled(); + expect( + dispatched.some((action) => action.type === 'FETCH_SUBMISSION_FAILURE'), + ).toBe(true); + }); + + it('stays on the page when the fetch succeeds', async () => { + CourseAPI.assessment.submissions.edit.mockResolvedValue({ + data: { + submission: { id: 42 }, + questions: [], + answers: [], + history: { questions: [] }, + }, + }); + + await runThunk(loadSubmissionPage(42)); + + expect(redirectToNotFound).not.toHaveBeenCalled(); + }); +}); diff --git a/client/app/bundles/course/assessment/submission/actions/index.js b/client/app/bundles/course/assessment/submission/actions/index.js index a033b0cb3a8..49819cbd72b 100644 --- a/client/app/bundles/course/assessment/submission/actions/index.js +++ b/client/app/bundles/course/assessment/submission/actions/index.js @@ -2,6 +2,7 @@ import GlobalAPI from 'api'; import CourseAPI from 'api/course'; import { setNotification } from 'lib/actions'; import pollJob from 'lib/helpers/jobHelpers'; +import { redirectToNotFound } from 'lib/hooks/router/redirect'; import actionTypes, { workflowStates } from '../constants'; import { @@ -66,7 +67,7 @@ export function getJobStatus(jobUrl) { return GlobalAPI.jobs.get(jobUrl); } -export function fetchSubmission(id, onGetMonitoringSessionId) { +export function fetchSubmission(id, onGetMonitoringSessionId, onError) { return (dispatch) => { dispatch({ type: actionTypes.FETCH_SUBMISSION_REQUEST }); @@ -103,13 +104,37 @@ export function fetchSubmission(id, onGetMonitoringSessionId) { }), ); }) - .catch(() => { + .catch((error) => { dispatch({ type: actionTypes.FETCH_SUBMISSION_FAILURE }); dispatch(resetExistingAnswerFlags()); + // Optional: lets a caller distinguish *why* the refetch failed. The marketplace preview + // banner uses it to tell a purged sandbox (404) apart from an ordinary failure. + onError?.(error); }); }; } +// The submission page's own load, as opposed to a refetch from somewhere already on the page. A 404 +// here means there is no such submission under this assessment — a typed, stale or guessed URL — +// and without this the page renders its normal shell with empty state, which reads as a broken page +// rather than a wrong address. `redirectToNotFound` rather than a not-found page rendered in place: +// this route sits inside the course shell, so rendering there would leave the sidebar and +// breadcrumbs around it, where the app's not-found page is standalone. The redirect carries the +// address, which that page restores, so the viewer still ends up looking at the URL they asked for. +// +// Deliberately a separate thunk rather than folding the 404 into `fetchSubmission`. The marketplace +// preview banner refetches through `fetchSubmission` and reads the very same 404 as a purged sandbox, +// for which it has its own message (`previewAutogradingSandboxGone`); redirecting on every 404 +// centrally would navigate that banner away instead. +export function loadSubmissionPage(id, onGetMonitoringSessionId) { + return (dispatch) => + dispatch( + fetchSubmission(id, onGetMonitoringSessionId, (error) => { + if (error?.response?.status === 404) redirectToNotFound(); + }), + ); +} + export function autogradeSubmission(id) { return (dispatch) => { dispatch({ type: actionTypes.AUTOGRADE_SUBMISSION_REQUEST }); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx index b26bc53812e..79f04beffc3 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx @@ -26,7 +26,7 @@ import assessmentsTranslations from '../../../translations'; import { enterStudentView, exitStudentView, - fetchSubmission, + loadSubmissionPage, purgeSubmissionStore, } from '../../actions'; import ProgressPanel from '../../components/ProgressPanel'; @@ -60,7 +60,7 @@ class VisibleSubmissionEditIndex extends Component { componentDidMount() { const { dispatch, match, setSessionId } = this.props; - dispatch(fetchSubmission(match.params.submissionId, setSessionId)); + dispatch(loadSubmissionPage(match.params.submissionId, setSessionId)); } componentWillUnmount() { diff --git a/client/app/lib/hooks/router/__test__/redirect.test.ts b/client/app/lib/hooks/router/__test__/redirect.test.ts new file mode 100644 index 00000000000..87f51ff07a3 --- /dev/null +++ b/client/app/lib/hooks/router/__test__/redirect.test.ts @@ -0,0 +1,52 @@ +import { getNotFoundSourceURL, getNotFoundURL } from '../redirect'; + +// The not-found page is standalone by design, so reaching it means leaving the page that 404ed. +// Carrying the address along is what lets that page put it back, so a viewer sent here still sees the +// URL they asked for rather than `/404` — which is how every other route to that page behaves. +describe('getNotFoundURL', () => { + it('carries the address it was called from', () => { + window.history.replaceState( + null, + '', + '/courses/8/assessments/33/submissions/818/edit', + ); + + expect(getNotFoundURL()).toBe( + '/404?from=%2Fcourses%2F8%2Fassessments%2F33%2Fsubmissions%2F818%2Fedit', + ); + }); + + it('carries the query string too', () => { + window.history.replaceState(null, '', '/courses/8/submissions/818?step=2'); + + expect(getNotFoundURL()).toBe( + '/404?from=%2Fcourses%2F8%2Fsubmissions%2F818%3Fstep%3D2', + ); + }); +}); + +describe('getNotFoundSourceURL', () => { + it('reads back the address a redirect carried', () => { + window.history.replaceState(null, '', '/courses/8/submissions/818?step=2'); + + expect(getNotFoundSourceURL(`http://localhost${getNotFoundURL()}`)).toBe( + '/courses/8/submissions/818?step=2', + ); + }); + + it('is null when there is none, as on the route catch-all', () => { + expect( + getNotFoundSourceURL('http://localhost/courses/8/nonsense'), + ).toBeNull(); + }); + + // Handed straight to `history.replaceState`, so a crafted `from` must not be able to rewrite the + // address bar to another origin. + it('reduces an off-origin address to a path on this one', () => { + expect( + getNotFoundSourceURL( + 'http://localhost/404?from=https%3A%2F%2Fevil.test%2Fx', + ), + ).toBe('/x'); + }); +}); diff --git a/client/app/lib/hooks/router/redirect.tsx b/client/app/lib/hooks/router/redirect.tsx index 9b623801cd0..6daf036aed2 100644 --- a/client/app/lib/hooks/router/redirect.tsx +++ b/client/app/lib/hooks/router/redirect.tsx @@ -51,8 +51,21 @@ export const redirectToSuspended = (): void => { window.location.href = url.pathname + url.search; }; +// Carries the address it was called from, the way `redirectToForbidden` and `redirectToSuspended` do. +// The not-found page is standalone, so reaching it means leaving the page that 404ed; without the +// source URL the viewer is shown `/404` instead of the address they actually asked for, which the +// route catch-all — every other way of reaching this page — never does. +// +// Split from the assignment like `getForbiddenURL` so the URL it builds can be asserted on: jsdom +// forbids stubbing `window.location`, so a test cannot observe the assignment itself. +export const getNotFoundURL = (): string => { + const url = new URL('/404', window.location.origin); + url.searchParams.append(FORBIDDEN_SOURCE_URL_SEARCH_PARAM, getCurrentURL()); + return url.pathname + url.search; +}; + export const redirectToNotFound = (): void => { - window.location.href = '/404'; + window.location.href = getNotFoundURL(); }; export const getForbiddenSourceURL = (rawURL: string): string | null => { @@ -65,6 +78,17 @@ export const getSuspendedSourceURL = (rawURL: string): string | null => { return url.searchParams.get(FORBIDDEN_SOURCE_URL_SEARCH_PARAM); }; +// Parsed defensively, unlike its two siblings: the not-found page hands this straight to +// `history.replaceState`, and `defensivelyParseURL` reduces whatever arrives to a path on this +// origin, so a crafted `?from=` cannot rewrite the address bar to somewhere else. +export const getNotFoundSourceURL = (rawURL: string): string | null => { + const sourceURL = new URL(rawURL).searchParams.get( + FORBIDDEN_SOURCE_URL_SEARCH_PARAM, + ); + + return sourceURL && defensivelyParseURL(sourceURL); +}; + /** * Redirects to the next URL if it exists, otherwise redirects to the home page. */ diff --git a/client/app/routers/course/__tests__/assessmentSubmissionRoutes.test.tsx b/client/app/routers/course/__tests__/assessmentSubmissionRoutes.test.tsx new file mode 100644 index 00000000000..4201b8920b6 --- /dev/null +++ b/client/app/routers/course/__tests__/assessmentSubmissionRoutes.test.tsx @@ -0,0 +1,101 @@ +import { + matchRoutes, + MemoryRouter, + RouteObject, + useLocation, + useRoutes, +} from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; + +import submissionsRouter from '../assessments/submissions'; +import courseRouter from '../index'; + +const t = ((descriptor: { defaultMessage?: string }): string => + descriptor.defaultMessage ?? '') as Parameters[0]; + +const matchedLeaf = (pathname: string): RouteObject | undefined => + matchRoutes([courseRouter(t)], pathname)?.at(-1)?.route; + +const matchedPaths = (pathname: string): (string | undefined)[] | null => + matchRoutes([courseRouter(t)], pathname)?.map(({ route }) => route.path) ?? + null; + +// The real router, with only the redirect's destination stubbed out, so the assertion is about our +// index route and our relative `to` rather than about a hand-built fixture. Mounting the genuine +// `edit` leaf would pull in the whole submission page. +const routerWithStubbedEditPage = (): RouteObject => { + const route = submissionsRouter(t); + const submission = route.children?.find( + (child) => child.path === ':submissionId', + ); + const edit = submission?.children?.find((child) => child.path === 'edit'); + + if (!edit) throw new Error('The :submissionId/edit route is missing.'); + + delete edit.lazy; + edit.element =
edit page
; + + return route; +}; + +// `useRoutes` rather than `createMemoryRouter`: the data router needs the fetch API globals, which +// jsdom does not define and which nothing in this suite's setup polyfills. It consumes the same route +// objects, and `Navigate`'s relative resolution — the thing under test — is identical either way. +const RoutedApp = (): JSX.Element | null => + useRoutes([ + { + path: 'courses/:courseId/assessments/:assessmentId', + children: [routerWithStubbedEditPage()], + }, + ]); + +const Pathname = (): JSX.Element => ( +
{useLocation().pathname}
+); + +describe('assessment submission routes', () => { + it('matches a submission page', () => { + expect( + matchedPaths('/courses/7/assessments/43/submissions/8183/edit'), + ).toEqual([ + 'courses/:courseId', + 'assessments', + ':assessmentId', + 'submissions', + ':submissionId', + 'edit', + ]); + }); + + // `/submissions/:id` is not a page of its own — the Rails resource has no `show`, and every + // submission link the app builds is an `edit_..._path`. It used to match the `:submissionId` route + // itself, which had children but no index: React Router treats a parent with a path as its own + // branch, so the URL rendered that route's empty default outlet — the course shell with a blank + // content area, for any id, real or invented. An index route both removes the dead end and gives the + // bare URL the meaning it should have had. + it.each([ + '/courses/7/assessments/43/submissions/8183', + '/courses/7/assessments/43/submissions/8183/', + '/courses/7/assessments/43/submissions/818', + ])('resolves %s to an index route rather than a dead end', (pathname) => { + expect(matchedLeaf(pathname)?.index).toBe(true); + }); + + // Whether the id exists, and whether this viewer may open it, is the backend's call once we are on + // `edit`: an id outside this assessment 404s, someone else's submission 403s, and your own renders. + it('redirects a bare submission url to its edit page', async () => { + render( + + + + , + ); + + expect(await screen.findByText('edit page')).toBeInTheDocument(); + expect(screen.getByTestId('pathname').textContent).toBe( + '/courses/7/assessments/43/submissions/8183/edit', + ); + }); +}); diff --git a/client/app/routers/course/assessments/submissions.tsx b/client/app/routers/course/assessments/submissions.tsx index 42a2a3ed30b..0a2c971aee1 100644 --- a/client/app/routers/course/assessments/submissions.tsx +++ b/client/app/routers/course/assessments/submissions.tsx @@ -1,4 +1,4 @@ -import { RouteObject } from 'react-router-dom'; +import { Navigate, RouteObject } from 'react-router-dom'; import { WithRequired } from 'types'; import { Translated } from 'lib/hooks/useTranslation'; @@ -25,6 +25,11 @@ const submissionsRouter: Translated = (_) => ({ { path: ':submissionId', children: [ + // Load-bearing: React Router makes a parent that has a path a matchable branch of its own, so + // without a child here `/submissions/:id` rendered this route's empty default outlet. `edit` is + // also the bare URL's right meaning — the Rails resource has no `show`, and every submission + // link the app builds is an `edit_course_assessment_submission_path`. + { index: true, element: }, { path: 'edit', lazy: async (): Promise> => { diff --git a/client/app/types/home.ts b/client/app/types/home.ts index 4b8cc7236e6..f0f66116a5b 100644 --- a/client/app/types/home.ts +++ b/client/app/types/home.ts @@ -25,4 +25,8 @@ export interface HomeLayoutData { timeZone: string | null; courses?: HomeLayoutCourseData[]; user?: HomeLayoutUserData; + // Whether the marketplace sandbox's read-only lock applies to THIS viewer. The courseless + // counterpart to `CourseLayoutData.isPreviewRestricted`, for shells that cannot read it off a + // course. False for a system administrator, who curates the preview container from inside it. + isPreviewRestricted?: boolean; } From 2086bbea4455b6378f8775e2ccd78915bc9aacb3 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:37:18 +0800 Subject: [PATCH 25/28] fix(submission): render the scribing canvas before its loading indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabric.js re-parents the raw node into its own container div, so a sibling rendered before it would need React to `insertBefore` relative to a node that is no longer a direct child — which throws on every toggle, e.g. a redundant re-initialize resetting `isCanvasLoaded`. Appending a trailing sibling never needs a reference node. --- .../ScribingView/ScribingCanvas.tsx | 12 ++- .../ScribingView/__test__/index.test.tsx | 99 ++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/client/app/bundles/course/assessment/submission/components/ScribingView/ScribingCanvas.tsx b/client/app/bundles/course/assessment/submission/components/ScribingView/ScribingCanvas.tsx index b89f67065f0..38cbe1cb03b 100644 --- a/client/app/bundles/course/assessment/submission/components/ScribingView/ScribingCanvas.tsx +++ b/client/app/bundles/course/assessment/submission/components/ScribingView/ScribingCanvas.tsx @@ -1331,13 +1331,23 @@ const ScribingCanvas = forwardRef( className="flex justify-center-safe bg-neutral-300 m-0 w-full outline-none items-center" style={{ minWidth: 800 }} > - {!scribingState[answerId]?.isCanvasLoaded ? : null} + {/* + * Rendered AFTER the canvas, never before: once Fabric.js constructs the + * canvas, it wraps the raw node in its own container div, + * re-parenting it out from under this div's direct children. A sibling + * rendered BEFORE the canvas would need React to `insertBefore` relative + * to that no-longer-direct-child node on every toggle (e.g. a redundant + * re-initialize resetting isCanvasLoaded) — which throws. Appending a + * trailing sibling never needs a reference node, so it's safe regardless + * of what Fabric has done to the canvas by then. + */} + {!scribingState[answerId]?.isCanvasLoaded ? : null}
); }, diff --git a/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx b/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx index e09fc1d8272..0d0dc8506f1 100644 --- a/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx +++ b/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx @@ -1,12 +1,37 @@ import { dispatch } from 'store'; -import { act, render } from 'test-utils'; +import { act, render, waitFor } from 'test-utils'; import { QuestionType } from 'types/course/assessment/question'; import { ScribingAnswerData } from 'types/course/assessment/submission/answer/scribing'; import ScribingView from 'course/assessment/submission/containers/ScribingView'; +import { LOADING_INDICATOR_TEST_ID } from 'lib/components/core/LoadingIndicator'; import { scribingActions } from '../../../reducers/scribing'; +/** + * jsdom never actually fetches/decodes images, so a real 's `load` event + * never fires for a fake asset URL — ScribingCanvas's own image-load effect + * (where it actually constructs the Fabric.js canvas) never runs under the + * default Image. This stub returns a REAL HTMLImageElement (so Fabric/canvas's + * `instanceof` checks on drawImage's source still pass), with `src` patched to + * fire `load` asynchronously, like a real browser would. + */ +function StubImage(): HTMLImageElement { + const img = document.createElement('img'); + Object.defineProperty(img, 'width', { value: 100, configurable: true }); + Object.defineProperty(img, 'height', { value: 100, configurable: true }); + let currentSrc = ''; + Object.defineProperty(img, 'src', { + configurable: true, + get: () => currentSrc, + set: (value: string) => { + currentSrc = value; + setTimeout(() => img.dispatchEvent(new Event('load')), 0); + }, + }); + return img; +} + const assessmentId = 1; const submissionId = 2; const answerId = 3; @@ -74,4 +99,76 @@ describe('ScribingView', () => { await page.findByTestId(`canvas-${answerId}`, {}, { timeout: 5000 }), ).toBeVisible(); }); + + describe('with a real Fabric.js canvas', () => { + const OriginalImage = global.Image; + + beforeAll(() => { + // @ts-expect-error — minimal stub, see StubImage above + global.Image = StubImage; + }); + + afterAll(() => { + global.Image = OriginalImage; + }); + + it('survives a redundant re-initialize after the canvas has already loaded', async () => { + // React reports this crash as an uncaught commit-phase error (via a + // dispatched DOM event), not as a rejected promise — `dispatch`/`act` + // above it don't throw. Catch it directly so the test fails clearly + // instead of just timing out waiting for a DOM update that never comes. + const uncaughtErrors: string[] = []; + const onWindowError = (event: ErrorEvent): void => { + uncaughtErrors.push(event.error?.message ?? event.message); + }; + window.addEventListener('error', onWindowError); + + try { + await act(() => + dispatch( + scribingActions.initialize({ answers: mockSubmission.answers }), + ), + ); + + const url = `/courses/${global.courseId}/assessments/${assessmentId}/submissions/${submissionId}/edit`; + const page = render(, { + at: [url], + }); + + // Wait for the component's own image-load effect to construct the real + // Fabric.js canvas — this is what wraps the in Fabric's own + // container div, re-parenting it out from under React's tree. + await waitFor( + () => + expect( + page.queryByTestId(LOADING_INDICATOR_TEST_ID), + ).not.toBeInTheDocument(), + { timeout: 5000 }, + ); + + // A duplicate FETCH_SUBMISSION_SUCCESS re-runs scribing/initialize, + // resetting isCanvasLoaded to false for an answer whose canvas Fabric.js + // has already taken over. Toggling the loading indicator back on then + // requires React to insertBefore a sibling relative to that + // no-longer-direct-child node — must not throw. + await act(() => + dispatch( + scribingActions.initialize({ answers: mockSubmission.answers }), + ), + ); + + expect(uncaughtErrors).toEqual([]); + expect( + await page.findByTestId( + LOADING_INDICATOR_TEST_ID, + {}, + { timeout: 5000 }, + ), + ).toBeVisible(); + expect(page.getByTestId(`canvas-${answerId}`)).toBeInTheDocument(); + } finally { + window.removeEventListener('error', onWindowError); + } + }); + }); }); From ea06272274fb2c82e757f84e4a32e7d70d4c2a95 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:37:18 +0800 Subject: [PATCH 26/28] fix(submission): don't call stopRecord when nothing was recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stopRecord()` rejects with "Recorder has already stopped", and nothing awaits it — so it becomes an unhandled rejection, surfacing as a full-page crash under StrictMode's dev-only double mount/unmount on every unrecorded Voice question. --- .../reducers/__test__/recorder.test.js | 59 +++++++++++++++++++ .../submission/reducers/recorder.js | 12 ++-- 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 client/app/bundles/course/assessment/submission/reducers/__test__/recorder.test.js diff --git a/client/app/bundles/course/assessment/submission/reducers/__test__/recorder.test.js b/client/app/bundles/course/assessment/submission/reducers/__test__/recorder.test.js new file mode 100644 index 00000000000..65e2f182937 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/reducers/__test__/recorder.test.js @@ -0,0 +1,59 @@ +import recorderHelper from '../../../utils/recorderHelper'; +import actionTypes from '../../constants'; +import reducer from '../recorder'; + +jest.mock('../../../utils/recorderHelper'); + +describe('recorder reducer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('RECORDER_COMPONENT_UNMOUNT', () => { + it('does not try to stop the recorder when nothing was recording', () => { + recorderHelper.isRecording.mockReturnValue(false); + const state = { + recording: false, + recorderComponentsCount: 1, + recordingComponentId: '', + }; + + reducer(state, { type: actionTypes.RECORDER_COMPONENT_UNMOUNT }); + + expect(recorderHelper.stopRecord).not.toHaveBeenCalled(); + }); + + it('stops the recorder when the user navigates away mid-recording', () => { + recorderHelper.isRecording.mockReturnValue(true); + recorderHelper.stopRecord.mockResolvedValue(new File([], 'test.wav')); + const state = { + recording: true, + recorderComponentsCount: 1, + recordingComponentId: 'voice_response_1', + }; + + reducer(state, { type: actionTypes.RECORDER_COMPONENT_UNMOUNT }); + + expect(recorderHelper.stopRecord).toHaveBeenCalled(); + }); + + it('does not decrement below the last unmount and resets recording state', () => { + recorderHelper.isRecording.mockReturnValue(false); + const state = { + recording: true, + recorderComponentsCount: 1, + recordingComponentId: 'voice_response_1', + }; + + const nextState = reducer(state, { + type: actionTypes.RECORDER_COMPONENT_UNMOUNT, + }); + + expect(nextState).toEqual({ + recording: false, + recorderComponentsCount: 0, + recordingComponentId: 'voice_response_1', + }); + }); + }); +}); diff --git a/client/app/bundles/course/assessment/submission/reducers/recorder.js b/client/app/bundles/course/assessment/submission/reducers/recorder.js index 59f8d1d2f8c..81b3ab2254b 100644 --- a/client/app/bundles/course/assessment/submission/reducers/recorder.js +++ b/client/app/bundles/course/assessment/submission/reducers/recorder.js @@ -38,11 +38,15 @@ export default function (state = initialState, action) { recording = false; /** - * When the user navigate to other path without stopping the recorder - * We need to help the user to stop + * When the user navigates to another path without stopping the recorder, + * help them stop it — but only if it was actually recording. Otherwise + * stopRecord() rejects with "Recorder has already stopped", and since + * nothing here awaits the promise, that becomes an unhandled rejection + * (surfaces as a full-page crash under React 18 StrictMode's dev-only + * double mount/unmount, since it fires on every unrecorded Voice question). */ - if (recorderComponentsCount === 0) { - recorderHelper.stopRecord(); + if (recorderComponentsCount === 0 && recorderHelper.isRecording()) { + recorderHelper.stopRecord().catch(() => {}); } return { ...state, From 55f003976b903bfaa67cb7362a32a9a6ad84e9bc Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:37:24 +0800 Subject: [PATCH 27/28] chore(marketplace): reap aged preview submissions on a TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly, keyed on last activity rather than creation so an in-progress rehearsal is never reaped out from under someone. Never touches the container course, the assessment copies or the previewers' enrolments — those are deliberately reused across preview sessions. The cron sets only how long past the TTL a submission may linger, not how long it is kept: starting over is the banner's Reset submission button, not this. --- .../preview_submission_reaping_job.rb | 59 +++++++++++++ config/schedule.yml | 9 ++ .../preview_submission_reaping_job_spec.rb | 82 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 app/jobs/course/assessment/marketplace/preview_submission_reaping_job.rb create mode 100644 spec/jobs/course/assessment/marketplace/preview_submission_reaping_job_spec.rb diff --git a/app/jobs/course/assessment/marketplace/preview_submission_reaping_job.rb b/app/jobs/course/assessment/marketplace/preview_submission_reaping_job.rb new file mode 100644 index 00000000000..d5a0d1df225 --- /dev/null +++ b/app/jobs/course/assessment/marketplace/preview_submission_reaping_job.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +# Reaps aged marketplace preview submissions on a TTL, scheduled weekly via `config/schedule.yml` +# (`Course::Assessment::Marketplace::PreviewContainerService`'s container course is the only source of +# these submissions). Never touches the container course, the assessment copies, or the previewers' +# enrolments — those are deliberately persistent and reused across preview sessions. +class Course::Assessment::Marketplace::PreviewSubmissionReapingJob < ApplicationJob + # Keyed on `updated_at` (last activity), not `created_at`, so an in-progress rehearsal is never + # reaped out from under someone still working, and async autograding has time to land before a + # destroy could race it. + # + # This is the floor on how long an attempt is kept, not the ceiling: the weekly cron means an aged + # submission may linger up to a week past it. That slack is tolerable only because reaping is no + # longer how a previewer starts over — `unique_assessment_id_and_creator_id` allows one submission + # per (assessment, previewer), and the way to clear it is the banner's "Reset submission" button + # (Course::Assessment::Marketplace::PreviewSubmissionsController), not waiting for the TTL. Tighten + # the cron, not just this constant, if that ceiling ever has to be guaranteed. + PREVIEW_SUBMISSION_TTL = 24.hours + + # Cap deletions per run to avoid bricking the worker (mirrors UserEmailDatabaseCleanupJob). Note + # this caps a WEEK's reaping, not an hour's: if preview volume ever exceeds it, aged submissions + # accumulate faster than they are removed and the cron needs raising before this does. + REAP_BATCH_SIZE = 1000 + + def perform + ActsAsTenant.without_tenant do + reap_aged_preview_submissions + end + end + + private + + def reap_aged_preview_submissions + User.with_stamper(User.system) do + Course::Assessment::Submission.transaction do + aged_preview_submissions.group_by(&:assessment).each do |assessment, submissions| + creator_ids = [] + submissions.each do |submission| + submission.destroy! + creator_ids << submission.creator_id + end + + Course::Assessment::Submission::MonitoringService.destroy_all_by(assessment, creator_ids) + end + end + end + end + + # Derived from the course, not a deep join: `Course::Assessment` is `acts_as` a + # `Course::LessonPlan::Item`, so a `joins(assessment: { tab: :category })` chain is fragile. + def aged_preview_submissions + preview_assessment_ids = Course.where(preview: true).flat_map { |course| course.assessments.pluck(:id) } + + Course::Assessment::Submission. + includes(:assessment). + where(assessment_id: preview_assessment_ids). + where(updated_at: ...PREVIEW_SUBMISSION_TTL.ago). + limit(REAP_BATCH_SIZE) + end +end diff --git a/config/schedule.yml b/config/schedule.yml index 8023ae56211..a9a7dcee090 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -15,3 +15,12 @@ user_email_database_cleanup_job: cron: '0 0 1 * *' class: 'UserEmailDatabaseCleanupJob' queue: 'default' + +# Reap aged marketplace preview submissions every Sunday at 21:20 UTC, 5:20 AM SGT Monday. The TTL +# is enforced in the job, not the cron, so this interval sets only how long past the TTL a submission +# may linger — not how long it is kept. Previewers do not wait on it to start over; the preview +# banner's "Reset submission" button clears an attempt on demand. +preview_submission_reaping_job: + cron: '20 21 * * 0' + class: 'Course::Assessment::Marketplace::PreviewSubmissionReapingJob' + queue: 'default' diff --git a/spec/jobs/course/assessment/marketplace/preview_submission_reaping_job_spec.rb b/spec/jobs/course/assessment/marketplace/preview_submission_reaping_job_spec.rb new file mode 100644 index 00000000000..53bf59bb8a0 --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/preview_submission_reaping_job_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PreviewSubmissionReapingJob, type: :job do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:ttl) { described_class::PREVIEW_SUBMISSION_TTL } + + subject { described_class.perform_now } + + # `update_column` bypasses `updated_at`'s own touch, unlike `update!`/`touch`. + def age!(submission, ago:) + submission.update_column(:updated_at, ago) + end + + context 'a preview submission older than the TTL' do + let!(:preview_course) { create(:course, preview: true) } + let!(:preview_assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let!(:previewer) { create(:user) } + let!(:aged_submission) do + submission = create(:submission, :attempting, assessment: preview_assessment, + course: preview_course, creator: previewer) + age!(submission, ago: (ttl + 1.hour).ago) + submission + end + + it 'reaps the aged preview submission' do + expect { subject }. + to change { Course::Assessment::Submission.exists?(aged_submission.id) }.from(true).to(false) + end + + it 'cascades: the reaped submission\'s answers are destroyed too' do + expect { subject }. + to change { Course::Assessment::Answer.where(submission_id: aged_submission.id).exists? }. + from(true).to(false) + end + + it 'leaves the preview course, the assessment, and the enrolment alone' do + subject + + expect(Course.exists?(preview_course.id)).to be(true) + expect(Course::Assessment.exists?(preview_assessment.id)).to be(true) + expect(preview_course.course_users.exists?(user_id: previewer.id)).to be(true) + end + end + + context 'a preview submission within the TTL grace period' do + let!(:preview_course) { create(:course, preview: true) } + let!(:preview_assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let!(:previewer) { create(:user) } + let!(:fresh_submission) do + create(:submission, :attempting, assessment: preview_assessment, + course: preview_course, creator: previewer) + end + + it 'spares the submission' do + expect { subject }. + not_to(change { Course::Assessment::Submission.exists?(fresh_submission.id) }) + end + end + + # The highest-value example: proves the job scopes to `preview: true` courses rather than + # reaping any old submission it finds. + context 'a NON-preview submission of the same age' do + let!(:normal_course) { create(:course) } + let!(:normal_assessment) { create(:assessment, :with_mcq_question, course: normal_course) } + let!(:normal_user) { create(:user) } + let!(:aged_normal_submission) do + submission = create(:submission, :attempting, assessment: normal_assessment, + course: normal_course, creator: normal_user) + age!(submission, ago: (ttl + 1.hour).ago) + submission + end + + it 'spares the non-preview submission' do + expect { subject }. + not_to(change { Course::Assessment::Submission.exists?(aged_normal_submission.id) }) + end + end + end +end From 3c9a87aaf3c9cec45d5961738d726170d58b6272 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 31 Jul 2026 16:37:29 +0800 Subject: [PATCH 28/28] docs(marketplace): drop dangling design-spec references from comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design doc these cite is local-only and unversioned, so "design §4.2" and friends point at nothing a reader of this repo can open. Each comment already says the thing it was citing. --- .../assessment/marketplace/questions_controller.rb | 2 +- .../assessment/marketplace_adoptions_controller.rb | 2 +- .../assessment/marketplace_listings_controller.rb | 2 +- .../system/admin/marketplace_listings_controller.rb | 4 ++-- .../course/assessment/marketplace/duplication_job.rb | 2 +- .../assessment/marketplace/restore_authoring_job.rb | 2 +- app/models/course/assessment/marketplace/adoption.rb | 2 +- .../assessment/marketplace/apply_version_service.rb | 2 +- .../course/assessment/marketplace/publish_service.rb | 10 +++++----- .../course/assessment/assessments/show.json.jbuilder | 2 +- .../pages/AssessmentShow/MarketplaceUpdateBanner.tsx | 4 ++-- ...add_marketplace_versioning_and_preview_container.rb | 6 +++--- .../marketplace/questions_controller_spec.rb | 2 +- .../assessment/marketplace/duplication_job_spec.rb | 2 +- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb index 22263efdff5..22a6b21ffe3 100644 --- a/app/controllers/course/assessment/marketplace/questions_controller.rb +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -8,7 +8,7 @@ def show includes(current_version: :assessment).find_by(id: params[:listing_id]) raise CanCan::AccessDenied unless listing - # The SNAPSHOT, never the authoring copy (design §4.2). + # The SNAPSHOT, never the authoring copy. @assessment = listing.current_version&.assessment raise CanCan::AccessDenied unless @assessment diff --git a/app/controllers/course/assessment/marketplace_adoptions_controller.rb b/app/controllers/course/assessment/marketplace_adoptions_controller.rb index 083ee7ccef5..1a631baabd4 100644 --- a/app/controllers/course/assessment/marketplace_adoptions_controller.rb +++ b/app/controllers/course/assessment/marketplace_adoptions_controller.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -# Adopter-side actions on a duplicated marketplace assessment (design §6.3). +# Adopter-side actions on a duplicated marketplace assessment. class Course::Assessment::MarketplaceAdoptionsController < Course::Assessment::Controller before_action :authorize_manage_assessment! diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb index 14a25defb1a..5020477ba67 100644 --- a/app/controllers/course/assessment/marketplace_listings_controller.rb +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -18,7 +18,7 @@ def create end # Cuts v(n+1) from the authoring copy. Deliberately separate from `create`: re-listing an unlisted - # assessment reactivates the row but must NOT silently republish changed content (design §5.2). + # assessment reactivates the row but must NOT silently republish changed content. def publish_version listing = @assessment.marketplace_listing return render json: { errors: ['Not listed on the marketplace.'] }, status: :unprocessable_content if listing.nil? diff --git a/app/controllers/system/admin/marketplace_listings_controller.rb b/app/controllers/system/admin/marketplace_listings_controller.rb index a337e66284b..c328e5886d4 100644 --- a/app/controllers/system/admin/marketplace_listings_controller.rb +++ b/app/controllers/system/admin/marketplace_listings_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # System-admin view of every marketplace listing — what version is served, how many courses adopted # it, and whether its source still exists — plus the maintenance actions on a listing off the -# marketplace: restore a source assessment (orphaned only), or delete it permanently (design §5.3). +# marketplace: restore a source assessment (orphaned only), or delete it permanently. # # `System::Admin::Controller` applies `before_action :authorize_admin` (`authorize!(:manage, :all)`), # which is the entire authorization story here. An ability check on the listing would not do: CanCan's @@ -16,7 +16,7 @@ def index @authoring_urls = authoring_urls(@listings) end - # The per-listing provenance + history page (design §4). Read-only: every mutation stays on the + # The per-listing provenance + history page. Read-only: every mutation stays on the # index. This is the ONLY index into the container course — publishing copies the assessment title # verbatim into a single shared tab, so version identity exists nowhere but the join table. def show diff --git a/app/jobs/course/assessment/marketplace/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb index dc521ca2f03..ba470376358 100644 --- a/app/jobs/course/assessment/marketplace/duplication_job.rb +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -30,7 +30,7 @@ def perform_tracked(listing_ids, destination_course, destination_tab_id, options private def duplicate_listing(listing, destination_course, current_user) - # The SNAPSHOT (design §4.2). `source.course` is therefore the hidden container course, which is + # `source.course` is the hidden container course, which is # exactly what `duplicate_objects` needs as its source course. source = listing.current_version.assessment copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( diff --git a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb index 9fde447df12..2b7e18353c8 100644 --- a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb +++ b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -# Un-orphans a listing (design §5.3): duplicates the listing's latest snapshot into the marketplace's +# Un-orphans a listing: duplicates the listing's latest snapshot into the marketplace's # own container course as a NEW, editable assessment and points `authoring_assessment` at it, so # `PublishService.publish_new_version` works again. # diff --git a/app/models/course/assessment/marketplace/adoption.rb b/app/models/course/assessment/marketplace/adoption.rb index 98706a77094..a5c8886cb31 100644 --- a/app/models/course/assessment/marketplace/adoption.rb +++ b/app/models/course/assessment/marketplace/adoption.rb @@ -8,7 +8,7 @@ class Course::Assessment::Marketplace::Adoption < ApplicationRecord validates :creator, presence: true validates :updater, presence: true - # Resolves the "a newer version is available" notice for an adopted assessment (design §6.1). + # Resolves the "a newer version is available" notice for an adopted assessment. # # Deliberately a pure timestamp comparison over adoptions / listings / listing_versions — the # container snapshot is never loaded, so this stays cheap enough to run on every assessment show. diff --git a/app/services/course/assessment/marketplace/apply_version_service.rb b/app/services/course/assessment/marketplace/apply_version_service.rb index d259d1984e0..59993b69c55 100644 --- a/app/services/course/assessment/marketplace/apply_version_service.rb +++ b/app/services/course/assessment/marketplace/apply_version_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true # Replaces an adopted copy's CONTENT with the version the marketplace currently serves, without -# replacing the assessment itself (2026-07-28 design §5). +# replacing the assessment itself. # # The copy keeps its id and therefore its URL, its tab position, its published state, its unlock # conditions and its adoption row. Only what the marketplace authored is overwritten. That is the diff --git a/app/services/course/assessment/marketplace/publish_service.rb b/app/services/course/assessment/marketplace/publish_service.rb index d8761257b04..8a458c0320e 100644 --- a/app/services/course/assessment/marketplace/publish_service.rb +++ b/app/services/course/assessment/marketplace/publish_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Publishes an assessment to the marketplace (copy-on-publish, design V2/§5.1): (re)activate the +# Publishes an assessment to the marketplace (copy-on-publish): (re)activate the # listing, capture provenance, snapshot the authoring assessment into the hidden container course, # and point `current_version` at the snapshot. `.publish` cuts v1 on first publish only — re-listing # an already-versioned listing does not cut a version; `.publish_new_version` is that explicit action. @@ -25,13 +25,13 @@ def self.ensure_first_version!(listing, publisher) new(listing.authoring_assessment, publisher).ensure_first_version!(listing) end - # Deliberate version cut (design §5.1). Snapshots whatever the authoring copy currently is into + # Deliberate version cut. Snapshots whatever the authoring copy currently is into # the container as version N+1 and advances `current_version`. Prior snapshots are retained — - # they are what Phase-3 comments and contributions will anchor to. + # they are what comments and contributions will anchor to. # # There is deliberately no content-diff gating: `Course::Assessment#updated_at` does not track # content changes, and walking the object graph misses edits below any fixed depth and misses - # deletions entirely (app/CLAUDE.md). The publisher decides when to cut. + # deletions entirely. The publisher decides when to cut. # # @param [Course::Assessment::Marketplace::Listing] listing # @param [User] publisher @@ -153,7 +153,7 @@ def activate_listing listing end - # Denormalized so the identity survives origin-course deletion (design §3.2): the course row is what + # Denormalized so the identity survives origin-course deletion: the course row is what # gets deleted, so its title is copied rather than read through `source_course`. def capture_provenance(listing) course = @assessment.course diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index 38dbcda2a30..253c2766bea 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -107,7 +107,7 @@ if @marketplace_version end # Null unless this assessment was copied from the marketplace AND a newer version has since been -# published that the adopter has neither dismissed nor muted (design §6.1). +# published that the adopter has neither dismissed nor muted. if @marketplace_update json.marketplaceUpdate do # Two fields, not four: the ordinal and the date were always the same fact twice. diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceUpdateBanner.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceUpdateBanner.tsx index e63507a8fd3..2c64b571c00 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceUpdateBanner.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceUpdateBanner.tsx @@ -18,8 +18,8 @@ interface Props { } /** - * Tells a course that already copied a marketplace assessment that a newer version exists - * (design §6.2). The copy deliberately avoids the words "sync" and "behind": the action replaces + * Tells a course that already copied a marketplace assessment that a newer version exists. + * The copy deliberately avoids the words "sync" and "behind": the action replaces * untouched local content in place. * * The notice cannot be dismissed or muted. It is a statement of fact about the copy rather than a diff --git a/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb b/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb index 7ff490881d7..ae3f89de986 100644 --- a/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb +++ b/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -# Marketplace versioning (design V2/V10/V17/§4.3–§5.1) in one migration: the marketplace stops +# Marketplace versioning in one migration: the marketplace stops # serving live source content and serves an immutable snapshot held in the preview container course. # # Deliberately one migration, not a schema/data pair per slice. The data backfill calls application @@ -41,7 +41,7 @@ def create_versions_table name: 'fk_camlv_listing_id', on_delete: :cascade }, index: { name: 'fk__camlv_listing_id' } - # A version IS its publication datetime (2026-07-28 design §2). There is no ordinal: an + # A version IS its publication datetime. There is no ordinal: an # integer would name a series the system cannot navigate — there is no rollback — and the # stable internal referent is already this row's primary key. t.datetime :published_at, null: false @@ -128,7 +128,7 @@ def remove_adoption_vintage_column remove_column :course_assessment_marketplace_adoptions, :adopted_version_at end - # Design V10/§4.3. `assessment_id` no longer means "what the marketplace shows" — that is now + # `assessment_id` does not mean "what the marketplace shows" — that is now # `current_version.assessment` (the container snapshot). The column becomes the nullable authoring # copy, and the FK flips cascade -> nullify so deleting the origin orphans the listing instead of # destroying it along with its version chain and every adopter's adoption row. diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index cb263d60a6a..3d915980e6f 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -20,7 +20,7 @@ end end let!(:listing) { publish(source_assessment) } - # The controller serves the container SNAPSHOT (design §4.2), so every question assertion must + # The controller serves the container SNAPSHOT, so every question assertion must # target the snapshot's copy, never the authoring original. let(:question) { snapshot_question(listing) } diff --git a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb index 05c52eb5d65..deb9f414386 100644 --- a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb +++ b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb @@ -11,7 +11,7 @@ title: "Marketplace source #{Process.pid}-#{object_id}") end # Published through the real service: the job duplicates the container SNAPSHOT, not the - # authoring copy (design §4.2), so the snapshot must be a genuine copy carrying the questions. + # authoring copy, so the snapshot must be a genuine copy carrying the questions. let(:listing) do Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) end