Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions client/app/api/course/Assessment/Submission/Answer/Answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ import { JobSubmitted } from 'types/jobs';
import { APIResponse } from 'api/types';

import BaseAPI from '../../Base';
import { getActivePreview } from '../../previewAttemptContext';
import SubmissionsAPI from '../../Submissions';

export default class AnswersAPI extends BaseAPI {
get #urlPrefix(): string {
// See Submissions.js#urlPrefix. A marketplace preview URL has no /assessments/:aid
// segment, so per-answer save/submit must route to the shallow attempt endpoint; the
// attempt id (this.submissionId, resolved from the preview URL) stands in as the
// submission id. The `assessmentId === null` disjunct survives a stray poller firing
// after the singleton was cleared, so it never emits /assessments/null/... .
if (getActivePreview() !== null || this.assessmentId === null) {
return `/courses/${this.courseId}/marketplace/attempt/${this.submissionId}/answers`;
}
return `/courses/${this.courseId}/assessments/${this.assessmentId}/submissions/${this.submissionId}/answers`;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BaseAssessmentAPI from '../../Base';
import { getActivePreview } from '../../previewAttemptContext';

export default class ScribingsAPI extends BaseAssessmentAPI {
/**
Expand All @@ -12,6 +13,12 @@ export default class ScribingsAPI extends BaseAssessmentAPI {
}

get #urlPrefix() {
// See Submissions.js#urlPrefix. In preview the path is keyed on the attempt id
// (the submission base-record id), which the browser path does not expose, so it
// comes from the singleton instead of `this.submissionId` (null on preview URLs).
if (getActivePreview() !== null || this.assessmentId === null) {
return `/courses/${this.courseId}/marketplace/attempt/${getActivePreview()}/answers`;
}
return `/courses/${this.courseId}/assessments/${this.assessmentId}/submissions/${this.submissionId}/answers`;
}
}
13 changes: 13 additions & 0 deletions client/app/api/course/Assessment/Submissions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BaseAssessmentAPI from './Base';
import { getActivePreview } from './previewAttemptContext';

export default class SubmissionsAPI extends BaseAssessmentAPI {
index() {
Expand Down Expand Up @@ -122,6 +123,10 @@ export default class SubmissionsAPI extends BaseAssessmentAPI {
return this.client.post(`${this.#urlPrefix}/${submissionId}/auto_grade`);
}

reset(submissionId) {
return this.client.post(`${this.#urlPrefix}/${submissionId}/reset`);
}

reevaluateAnswer(submissionId, params) {
return this.client.post(
`${this.#urlPrefix}/${submissionId}/reevaluate_answer`,
Expand Down Expand Up @@ -198,6 +203,14 @@ export default class SubmissionsAPI extends BaseAssessmentAPI {
}

get #urlPrefix() {
// Preview routing triggers on EITHER the explicit context OR the absence of an
// assessment id in the URL. The only reuse of this API on a non-/assessments/:id
// URL is the marketplace preview page, so `this.assessmentId === null` is a robust
// fallback that survives a stray poller firing after the singleton was cleared:
// it routes to /marketplace/attempt/... rather than ever emitting /assessments/null/...
if (getActivePreview() !== null || this.assessmentId === null) {
return `/courses/${this.courseId}/marketplace/attempt`;
}
return `/courses/${this.courseId}/assessments/${this.assessmentId}/submissions`;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
clearActivePreview,
getActivePreview,
setActivePreview,
} from '../previewAttemptContext';

describe('previewAttemptContext', () => {
afterEach(() => clearActivePreview());

it('is null by default', () => {
expect(getActivePreview()).toBeNull();
});

it('returns the id after setActivePreview', () => {
setActivePreview(42);
expect(getActivePreview()).toBe(42);
});

it('returns null after clearActivePreview', () => {
setActivePreview(42);
clearActivePreview();
expect(getActivePreview()).toBeNull();
});
});
203 changes: 203 additions & 0 deletions client/app/api/course/Assessment/__test__/previewRouting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { createMockAdapter } from 'mocks/axiosMock';

import CourseAPI from 'api/course';

import { clearActivePreview, setActivePreview } from '../previewAttemptContext';

// The submission API reads the assessment id from window.location. Preview URLs have
// none, so the seam must fall back to preview routing even without the singleton.
const setPath = (path: string): void => {
window.history.pushState({}, '', path);
};

const submissionsMock = createMockAdapter(
CourseAPI.assessment.submissions.client,
);
const scribingMock = createMockAdapter(
CourseAPI.assessment.answer.scribing.client,
);
const answerMock = createMockAdapter(CourseAPI.assessment.answer.answer.client);
const marketplaceMock = createMockAdapter(CourseAPI.marketplace.client);

beforeEach(() => {
submissionsMock.reset();
scribingMock.reset();
answerMock.reset();
marketplaceMock.reset();
clearActivePreview();
});
afterEach(() => clearActivePreview());

describe('submission API preview routing', () => {
it('routes edit to /assessments/:aid/submissions when NOT in preview', async () => {
setPath(`/courses/${global.courseId}/assessments/9/submissions/5/edit`);
submissionsMock
.onGet(`/courses/${global.courseId}/assessments/9/submissions/5/edit`)
.reply(200, {});

await CourseAPI.assessment.submissions.edit(5);

expect(submissionsMock.history.get[0].url).toBe(
`/courses/${global.courseId}/assessments/9/submissions/5/edit`,
);
});

it('routes edit to /marketplace/attempt when the singleton is set', async () => {
setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`);
setActivePreview(5);
submissionsMock
.onGet(`/courses/${global.courseId}/marketplace/attempt/5/edit`)
.reply(200, {});

await CourseAPI.assessment.submissions.edit(5);

expect(submissionsMock.history.get[0].url).toBe(
`/courses/${global.courseId}/marketplace/attempt/5/edit`,
);
});

it('routes to /marketplace/attempt when assessment id is absent even if the singleton was cleared (stray poller)', async () => {
setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`);
clearActivePreview();
submissionsMock
.onGet(
`/courses/${global.courseId}/marketplace/attempt/fetch_live_feedback_status`,
)
.reply(200, {});

await CourseAPI.assessment.submissions.fetchLiveFeedbackStatus('t-1');

// The critical assertion: it must NOT be /assessments/null/...
expect(submissionsMock.history.get[0].url).toBe(
`/courses/${global.courseId}/marketplace/attempt/fetch_live_feedback_status`,
);
expect(submissionsMock.history.get[0].url).not.toContain(
'assessments/null',
);
});

it('routes reset to /marketplace/attempt/:id/reset', async () => {
setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`);
setActivePreview(5);
submissionsMock
.onPost(`/courses/${global.courseId}/marketplace/attempt/5/reset`)
.reply(200, {});

await CourseAPI.assessment.submissions.reset(5);

expect(submissionsMock.history.post[0].url).toBe(
`/courses/${global.courseId}/marketplace/attempt/5/reset`,
);
});
});

describe('answer API preview routing', () => {
it('routes saveDraft to /assessments/:aid/submissions/:sid/answers when NOT in preview', async () => {
setPath(`/courses/${global.courseId}/assessments/9/submissions/5/edit`);
answerMock
.onPatch(
`/courses/${global.courseId}/assessments/9/submissions/5/answers/7`,
)
.reply(200, {});

await CourseAPI.assessment.answer.answer.saveDraft(7, {});

expect(answerMock.history.patch[0].url).toBe(
`/courses/${global.courseId}/assessments/9/submissions/5/answers/7`,
);
});

it('routes saveDraft to /marketplace/attempt/:attemptId/answers/:answerId in preview', async () => {
setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`);
setActivePreview(5);
answerMock
.onPatch(`/courses/${global.courseId}/marketplace/attempt/5/answers/7`)
.reply(200, {});

await CourseAPI.assessment.answer.answer.saveDraft(7, {});

expect(answerMock.history.patch[0].url).toBe(
`/courses/${global.courseId}/marketplace/attempt/5/answers/7`,
);
expect(answerMock.history.patch[0].url).not.toContain('assessments/null');
});

it('routes saveDraft to /marketplace/attempt even after the singleton was cleared (stray poller)', async () => {
setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`);
clearActivePreview();
answerMock
.onPatch(`/courses/${global.courseId}/marketplace/attempt/5/answers/7`)
.reply(200, {});

await CourseAPI.assessment.answer.answer.saveDraft(7, {});

expect(answerMock.history.patch[0].url).toBe(
`/courses/${global.courseId}/marketplace/attempt/5/answers/7`,
);
expect(answerMock.history.patch[0].url).not.toContain('assessments/null');
});

it('routes submitAnswer to /marketplace/attempt/:attemptId/answers/:answerId/submit_answer in preview', async () => {
setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`);
setActivePreview(5);
answerMock
.onPatch(
`/courses/${global.courseId}/marketplace/attempt/5/answers/7/submit_answer`,
)
.reply(200, {});

await CourseAPI.assessment.answer.answer.submitAnswer(7, {});

expect(answerMock.history.patch[0].url).toBe(
`/courses/${global.courseId}/marketplace/attempt/5/answers/7/submit_answer`,
);
});
});

describe('scribing API preview routing', () => {
it('routes scribble update to /assessments/:aid/submissions/:sid/answers when NOT in preview', async () => {
setPath(`/courses/${global.courseId}/assessments/9/submissions/5/edit`);
scribingMock
.onPost(
`/courses/${global.courseId}/assessments/9/submissions/5/answers/7/scribing/scribbles`,
)
.reply(200, {});

await CourseAPI.assessment.answer.scribing.update(7, {});

expect(scribingMock.history.post[0].url).toBe(
`/courses/${global.courseId}/assessments/9/submissions/5/answers/7/scribing/scribbles`,
);
});

it('routes scribble update to /marketplace/attempt/:attemptId/answers when in preview', async () => {
setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`);
setActivePreview(5);
scribingMock
.onPost(
`/courses/${global.courseId}/marketplace/attempt/5/answers/7/scribing/scribbles`,
)
.reply(200, {});

await CourseAPI.assessment.answer.scribing.update(7, {});

expect(scribingMock.history.post[0].url).toBe(
`/courses/${global.courseId}/marketplace/attempt/5/answers/7/scribing/scribbles`,
);
});
});

describe('marketplace createAttempt', () => {
it('posts to listings/:listingId/attempt and returns id + assessmentId', async () => {
marketplaceMock
.onPost(`/courses/${global.courseId}/marketplace/listings/7/attempt`)
.reply(200, { id: 55, assessmentId: 9 });

const response = await CourseAPI.marketplace.createAttempt(7);

expect(marketplaceMock.history.post[0].url).toBe(
`/courses/${global.courseId}/marketplace/listings/7/attempt`,
);
expect(response.data).toEqual({ id: 55, assessmentId: 9 });
});
});
22 changes: 22 additions & 0 deletions client/app/api/course/Assessment/previewAttemptContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Module-level marker for "the current page is a marketplace preview attempt".
*
* The reused submission API (`SubmissionsAPI`, `ScribingsAPI`) builds its URLs from
* the browser path. A preview URL (`/courses/:c/marketplace/attempt/:id`) has no
* `/assessments/:aid` segment, so the API must be told to route to the shallow
* `/marketplace/attempt/...` endpoints instead. This singleton is that signal.
*
* It is a plain module variable (not React context) because the API layer runs
* outside React and cannot read context.
*/
let activePreviewAttemptId: number | null = null;

export const setActivePreview = (attemptId: number): void => {
activePreviewAttemptId = attemptId;
};

export const clearActivePreview = (): void => {
activePreviewAttemptId = null;
};

export const getActivePreview = (): number | null => activePreviewAttemptId;
6 changes: 6 additions & 0 deletions client/app/api/course/Marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,10 @@ export default class MarketplaceAPI extends BaseCourseAPI {
`${this.#urlPrefix}/listings/${listingId}/questions/${questionId}`,
);
}

createAttempt(
listingId: number,
): Promise<AxiosResponse<{ id: number; assessmentId: number }>> {
return this.client.post(`${this.#urlPrefix}/listings/${listingId}/attempt`);
}
}
Loading