From 73a9fc5d5ed6f1b8f0ab6b46829909f3e2d5964c Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 8 Jul 2026 09:36:20 +0800 Subject: [PATCH] feat(marketplace): admin publish control for assessments - add publish/remove listing endpoints (admin-gated create/destroy) - expose canPublishToMarketplace + listing state on assessment show DTO - add Publish/Remove to Marketplace button on the assessment header - warn in the delete Prompt when a listed assessment is removed - add MarketplaceAPI client, translations, and controller/FE specs --- .../marketplace_listings_controller.rb | 44 +++++++++ .../assessment/assessments/show.json.jbuilder | 4 + client/app/api/course/Marketplace.ts | 21 +++++ client/app/api/course/index.js | 2 + .../AssessmentShow/AssessmentShowHeader.tsx | 18 ++++ .../__test__/AssessmentShowHeader.test.tsx | 90 ++++++++++++++++++ .../components/PublishToMarketplaceButton.tsx | 90 ++++++++++++++++++ .../PublishToMarketplaceButton.test.tsx | 93 +++++++++++++++++++ .../course/marketplace/translations.ts | 51 ++++++++++ .../types/course/assessment/assessments.ts | 3 + client/locales/en.json | 33 +++++++ client/locales/ko.json | 33 +++++++ client/locales/zh.json | 33 +++++++ config/routes.rb | 2 + .../assessments_marketplace_spec.rb | 43 +++++++++ .../marketplace_listings_controller_spec.rb | 84 +++++++++++++++++ 16 files changed, 644 insertions(+) create mode 100644 app/controllers/course/assessment/marketplace_listings_controller.rb create mode 100644 client/app/api/course/Marketplace.ts create mode 100644 client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx create mode 100644 client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx create mode 100644 client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx create mode 100644 client/app/bundles/course/marketplace/translations.ts create mode 100644 spec/controllers/course/assessment/assessments_marketplace_spec.rb create mode 100644 spec/controllers/course/assessment/marketplace_listings_controller_spec.rb diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb new file mode 100644 index 00000000000..a05ec766cb0 --- /dev/null +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller + before_action :authorize_publish_to_marketplace! + + def create + listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment) + now = Time.zone.now + listing.published = true + listing.first_published_at ||= now + listing.last_published_at = now + # `publisher` is an audit userstamp for the *latest* publish (design D29), so it moves with + # `last_published_at`. `creator` already retains whoever first created the row. + 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 + end + + def destroy + listing = @assessment.marketplace_listing + if listing&.update(published: false) + head :ok + else + head :unprocessable_content + end + end + + private + + # Publishing is admin-only. `authorize!(:publish_to_marketplace, @assessment)` alone is + # insufficient: teaching staff hold `can :manage, Course::Assessment` over their own course's + # assessments (assessment_ability.rb:189), and CanCan's `:manage` wildcard subsumes every + # custom action — including `:publish_to_marketplace`. Gate explicitly on administrator status. + def authorize_publish_to_marketplace! + authorize!(:publish_to_marketplace, @assessment) + raise CanCan::AccessDenied unless current_user&.administrator? + end + + def component + current_component_host[:course_assessments_component] + end +end diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index d22f4b56bf4..b801cc43559 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -77,8 +77,12 @@ json.permissions do json.canManage can_manage json.canObserve can_observe json.canInviteToKoditsu can?(:invite_to_koditsu, assessment) + json.canPublishToMarketplace((can?(:publish_to_marketplace, @assessment) && current_user&.administrator?) || false) end +json.isPublishedToMarketplace @assessment.marketplace_listing&.published? || false +json.marketplaceListingUrl course_assessment_marketplace_listing_path(current_course, @assessment) + unless can_attempt not_started_for_user = assessment_not_started(assessment.time_for(current_course_user)) json.willStartAt assessment.time_for(current_course_user).start_at if not_started_for_user diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts new file mode 100644 index 00000000000..d06a224111e --- /dev/null +++ b/client/app/api/course/Marketplace.ts @@ -0,0 +1,21 @@ +import { AxiosResponse } from 'axios'; + +import BaseCourseAPI from './Base'; + +export default class MarketplaceAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/marketplace`; + } + + publishListing(assessmentId: number): Promise { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } + + removeListing(assessmentId: number): Promise { + return this.client.delete( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 355a5878c53..8087e014bc7 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -18,6 +18,7 @@ import LeaderboardAPI from './Leaderboard'; import LearningMapAPI from './LearningMap'; import LessonPlanAPI from './LessonPlan'; import LevelAPI from './Level'; +import MarketplaceAPI from './Marketplace'; import MaterialFoldersAPI from './MaterialFolders'; import MaterialsAPI from './Materials'; import PersonalTimesAPI from './PersonalTimes'; @@ -55,6 +56,7 @@ const CourseAPI = { learningMap: new LearningMapAPI(), lessonPlan: new LessonPlanAPI(), level: new LevelAPI(), + marketplace: new MarketplaceAPI(), materials: new MaterialsAPI(), materialFolders: new MaterialFoldersAPI(), personalTimes: new PersonalTimesAPI(), diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx index 3d9b1be88a9..0837787ab82 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx @@ -13,6 +13,8 @@ import { AssessmentDeleteResult, } from 'types/course/assessment/assessments'; +import PublishToMarketplaceButton from 'course/marketplace/components/PublishToMarketplaceButton'; +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'; @@ -37,6 +39,9 @@ const AssessmentShowHeader = ( const { t } = useTranslation(); const [deleting, setDeleting] = useState(false); const [inviting, setInviting] = useState(false); + const [publishedToMarketplace, setPublishedToMarketplace] = useState( + assessment.isPublishedToMarketplace, + ); const navigate = useNavigate(); const handleDelete = (): Promise => { @@ -75,6 +80,9 @@ const AssessmentShowHeader = ( {t(translations.deletingThisAssessment)} {assessment.title} {t(translations.deleteAssessmentWarning)} + {publishedToMarketplace && ( + {t(marketplaceTranslations.deleteWarning)} + )} )} @@ -146,6 +154,16 @@ const AssessmentShowHeader = ( )} + {assessment.permissions.canPublishToMarketplace && ( + + )} + {assessment.actionButtonUrl && ( mock.reset()); + +// Minimal AssessmentData: only `deleteUrl` + `title` are needed for the delete +// Prompt to render (see AssessmentShowHeader.tsx:71 / DeleteButton.tsx). All other +// action buttons stay hidden by leaving their URLs undefined, and the publish +// button stays hidden via `canPublishToMarketplace: false`. +const baseAssessment = { + id: 1, + title: 'Sample Assessment', + deleteUrl: '/courses/1/assessments/1', + status: 'open', + permissions: { + canAttempt: false, + canManage: true, + canObserve: true, + canInviteToKoditsu: false, + canPublishToMarketplace: false, + }, + isPublishedToMarketplace: false, +}; + +// 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; + +describe('', () => { + it('warns that deletion removes the marketplace listing 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 + expect(page.getByText(MARKETPLACE_WARNING)).toBeVisible(); + }); + + 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 + expect(page.queryByText(MARKETPLACE_WARNING)).not.toBeInTheDocument(); + }); + + it('warns after the assessment is published in the same session', async () => { + mock + .onPost(`/courses/${global.courseId}/assessments/1/marketplace_listing`) + .reply(200, { published: true }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button + const publishPrompt = await page.findByRole('dialog'); + fireEvent.click( + within(publishPrompt).getByRole('button', { + name: /Publish to Marketplace/, + }), + ); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + // The warning reads the live state, not the initial `isPublishedToMarketplace` prop. + fireEvent.click(page.getByLabelText('Delete Assessment')); + expect(await page.findByText(MARKETPLACE_WARNING)).toBeVisible(); + }); +}); diff --git a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx new file mode 100644 index 00000000000..05a2c069bf7 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Button } from '@mui/material'; +import { AssessmentData } from 'types/course/assessment/assessments'; + +import CourseAPI from 'api/course'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import toast from 'lib/hooks/toast'; + +import translations from '../translations'; + +interface Props { + assessment: Pick< + AssessmentData, + 'id' | 'isPublishedToMarketplace' | 'permissions' + >; + onChange: (published: boolean) => void; +} + +const PublishToMarketplaceButton = ({ + assessment, + onChange, +}: Props): JSX.Element | null => { + const { formatMessage: t } = useIntl(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const listed = assessment.isPublishedToMarketplace; + + if (!assessment.permissions.canPublishToMarketplace) return null; + + // `Prompt`'s primary button does not await this handler, so every rejection must be caught + // here: an uncaught one surfaces nothing to the user and becomes an unhandled rejection. + const confirm = async (): Promise => { + setSubmitting(true); + try { + if (listed) { + await CourseAPI.marketplace.removeListing(assessment.id); + toast.success(t(translations.removed)); + onChange(false); + } else { + await CourseAPI.marketplace.publishListing(assessment.id); + toast.success(t(translations.published)); + onChange(true); + } + setOpen(false); + } catch { + // Dialog stays open so the user can retry. + toast.error( + t(listed ? translations.removeFailed : translations.publishFailed), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor={listed ? 'error' : 'primary'} + primaryLabel={t(listed ? translations.remove : translations.publish)} + title={t( + listed + ? translations.removeConfirmTitle + : translations.publishConfirmTitle, + )} + > + + {t( + listed + ? translations.removeConfirmBody + : translations.publishConfirmBody, + )} + + + + ); +}; + +export default PublishToMarketplaceButton; diff --git a/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx new file mode 100644 index 00000000000..3639f497ae2 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx @@ -0,0 +1,93 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import PublishToMarketplaceButton from '../PublishToMarketplaceButton'; + +const confirmInDialog = async ( + page: ReturnType, + name: RegExp, +): Promise => { + const dialog = await page.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name })); +}; + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +const assessmentAt = ( + isPublishedToMarketplace: boolean, + canPublishToMarketplace = true, +): never => + ({ + id: 5, + isPublishedToMarketplace, + permissions: { canPublishToMarketplace }, + }) as never; + +const url = `/courses/${global.courseId}/assessments/5/marketplace_listing`; + +it('renders nothing when the user cannot publish', () => { + const page = render( + , + ); + expect(page.queryByText('Publish to Marketplace')).not.toBeInTheDocument(); + expect(page.queryByText('Remove from Marketplace')).not.toBeInTheDocument(); +}); + +it('publishes after confirming and reports published=true', async () => { + mock.onPost(url).reply(200, { published: true }); + const onChange = jest.fn(); + const page = render( + , + ); + + // findByText: test-utils wraps the tree in a translations Suspense whose fallback is a + // LoadingIndicator; the trigger button only exists after messages resolve. + fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button + await confirmInDialog(page, /Publish to Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(true); +}); + +it('removes after confirming when already listed, reports published=false', async () => { + mock.onDelete(url).reply(200); + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByText('Remove from Marketplace')); // trigger button + await confirmInDialog(page, /Remove from Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(false); +}); + +it('surfaces an error and keeps the dialog open when publishing fails', async () => { + mock.onPost(url).reply(422, { errors: ['nope'] }); + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByText('Publish to Marketplace')); + await confirmInDialog(page, /Publish to Marketplace/); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + expect(await page.findByText(/Failed to publish/i)).toBeVisible(); // error toast + expect(page.getByRole('dialog')).toBeVisible(); // still open, so the user can retry + expect(onChange).not.toHaveBeenCalled(); +}); diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts new file mode 100644 index 00000000000..3f3125004f8 --- /dev/null +++ b/client/app/bundles/course/marketplace/translations.ts @@ -0,0 +1,51 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + publish: { + id: 'course.marketplace.publish', + defaultMessage: 'Publish to Marketplace', + }, + remove: { + id: 'course.marketplace.remove', + defaultMessage: 'Remove from Marketplace', + }, + publishConfirmTitle: { + id: 'course.marketplace.publishConfirmTitle', + defaultMessage: 'Publish to Marketplace?', + }, + publishConfirmBody: { + id: 'course.marketplace.publishConfirmBody', + defaultMessage: + 'This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description.', + }, + removeConfirmTitle: { + id: 'course.marketplace.removeConfirmTitle', + defaultMessage: 'Remove from Marketplace?', + }, + removeConfirmBody: { + id: 'course.marketplace.removeConfirmBody', + defaultMessage: + 'It will no longer appear in the marketplace. Existing copies are unaffected.', + }, + published: { + id: 'course.marketplace.publishedToast', + defaultMessage: 'Published to the marketplace.', + }, + removed: { + id: 'course.marketplace.removedToast', + defaultMessage: 'Removed from the marketplace.', + }, + publishFailed: { + id: 'course.marketplace.publishFailedToast', + defaultMessage: 'Failed to publish to the marketplace. Please try again.', + }, + removeFailed: { + id: 'course.marketplace.removeFailedToast', + defaultMessage: 'Failed to remove from the marketplace. Please try again.', + }, + deleteWarning: { + id: 'course.marketplace.deleteWarning', + defaultMessage: + 'This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected.', + }, +}); diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 1fcdcb66319..f23d634b075 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -106,7 +106,10 @@ export interface AssessmentData extends AssessmentActionsData { canManage: boolean; canObserve: boolean; canInviteToKoditsu: boolean; + canPublishToMarketplace: boolean; }; + isPublishedToMarketplace: boolean; + marketplaceListingUrl: string; requirements: { title: string; satisfied?: boolean; diff --git a/client/locales/en.json b/client/locales/en.json index a655ccd405d..014a14d4f6e 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6062,6 +6062,39 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "Experience points threshold cannot be 0" }, + "course.marketplace.publish": { + "defaultMessage": "Publish to Marketplace" + }, + "course.marketplace.remove": { + "defaultMessage": "Remove from Marketplace" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "Publish to Marketplace?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "Remove from Marketplace?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "It will no longer appear in the marketplace. Existing copies are unaffected." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "Published to the marketplace." + }, + "course.marketplace.removedToast": { + "defaultMessage": "Removed from the marketplace." + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "Failed to publish to the marketplace. Please try again." + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "Failed to remove from the marketplace. Please try again." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected." + }, "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 34fa37efc36..e44cce35203 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6026,6 +6026,39 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "경험치 기준은 0이 될 수 없습니다" }, + "course.marketplace.publish": { + "defaultMessage": "마켓플레이스에 게시" + }, + "course.marketplace.remove": { + "defaultMessage": "마켓플레이스에서 제거" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "마켓플레이스에 게시하시겠습니까?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "이 평가는 강좌 관리자가 찾아볼 수 있으며, 미리보기 및 복제할 수 있습니다. 이 평가의 자체 제목과 설명이 사용됩니다." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "마켓플레이스에서 제거하시겠습니까?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "더 이상 마켓플레이스에 표시되지 않습니다. 기존 복사본은 영향을 받지 않습니다." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "마켓플레이스에 게시되었습니다." + }, + "course.marketplace.removedToast": { + "defaultMessage": "마켓플레이스에서 제거되었습니다." + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "마켓플레이스에 게시하지 못했습니다. 다시 시도해 주세요." + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "마켓플레이스에서 제거하지 못했습니다. 다시 시도해 주세요." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "이 평가는 평가 마켓플레이스에 있습니다. 삭제하면 마켓플레이스에서 제거되고 채택 기록도 삭제됩니다. 다른 강좌의 기존 복사본은 영향을 받지 않습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 42e4ee87035..e8020d3d115 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6020,6 +6020,39 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "经验值阈值不能为0" }, + "course.marketplace.publish": { + "defaultMessage": "发布到市场" + }, + "course.marketplace.remove": { + "defaultMessage": "从市场移除" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "发布到市场?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "课程管理员可以浏览此评估,并可预览和复制它。它会使用此评估自身的标题和描述。" + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "从市场移除?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "它将不再显示在市场中。现有副本不受影响。" + }, + "course.marketplace.publishedToast": { + "defaultMessage": "已发布到市场。" + }, + "course.marketplace.removedToast": { + "defaultMessage": "已从市场移除。" + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "发布到市场失败,请重试。" + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "从市场移除失败,请重试。" + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "此评估位于评估市场中。删除它会将其从市场移除,并删除其采用历史记录。其他课程中的现有副本不受影响。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, diff --git a/config/routes.rb b/config/routes.rb index a65da4a24dd..bf673706ae7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -287,6 +287,8 @@ resources :mock_answers, on: :member, only: [:index, :create, :update, :destroy] end + resource :marketplace_listing, only: [:create, :destroy] + namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do post :generate, on: :collection diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb new file mode 100644 index 00000000000..8c0b966a9ad --- /dev/null +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::AssessmentsController, 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 #show — marketplace fields' do + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'grants the publish permission and reports not-yet-published' do + get :show, as: :json, params: { course_id: course, id: assessment } + body = JSON.parse(response.body) + expect(body['permissions']).to include('canPublishToMarketplace' => true) + expect(body).to include('isPublishedToMarketplace' => false) + expect(body['marketplaceListingUrl']).to be_present + end + + it 'reports isPublishedToMarketplace true once a published listing exists' do + create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)).to include('isPublishedToMarketplace' => true) + end + end + + context 'as a course manager (non-admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + + it 'withholds the publish permission' do + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)['permissions']).to include('canPublishToMarketplace' => false) + end + end + end + end +end diff --git a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb new file mode 100644 index 00000000000..2f261b11b01 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::MarketplaceListingsController, type: :controller do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + subject { post :create, params: { course_id: course, assessment_id: assessment, format: :json } } + + it 'creates a published listing' do + expect { subject }.to change { Course::Assessment::Marketplace::Listing.count }.by(1) + listing = assessment.reload.marketplace_listing + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_present + expect(listing.last_published_at).to be_present + expect(listing.publisher).to eq(admin) + end + + context 'when the assessment was previously published then removed (re-publish)' do + let!(:listing) do + create(:course_assessment_marketplace_listing, assessment: assessment, published: false, + first_published_at: 3.days.ago, last_published_at: 3.days.ago) + end + + it 'reuses the existing row, preserves first_published_at, bumps last_published_at' do + original_first = listing.first_published_at + expect { subject }.not_to(change { Course::Assessment::Marketplace::Listing.count }) + listing.reload + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_within(1.second).of(original_first) # NOT overwritten + expect(listing.last_published_at).to be > original_first # bumped to now + end + + it 'stamps the re-publishing admin as the publisher' do + expect(listing.publisher).not_to eq(admin) # factory publisher: the course creator + subject + expect(listing.reload.publisher).to eq(admin) # moves with last_published_at + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + end + + describe 'DELETE #destroy' do + let!(:listing) { create(:course_assessment_marketplace_listing, assessment: assessment, published: true) } + + it 'soft-removes: keeps the row, sets published false' do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + expect(listing.reload.published).to be(false) + expect(Course::Assessment::Marketplace::Listing.exists?(listing.id)).to be(true) + end + + context 'when the assessment has no marketplace listing' do + let(:unlisted_assessment) { create(:assessment, course: course) } + + it 'responds unprocessable' do + delete :destroy, params: { course_id: course, assessment_id: unlisted_assessment, format: :json } + expect(response).to have_http_status(:unprocessable_content) + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it 'is forbidden and leaves the listing published' do + expect do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + expect(listing.reload.published).to be(true) + end + end + end + end +end