diff --git a/codewit/api/src/controllers/module.spec.ts b/codewit/api/src/controllers/module.spec.ts new file mode 100644 index 00000000..97308feb --- /dev/null +++ b/codewit/api/src/controllers/module.spec.ts @@ -0,0 +1,112 @@ +import { + Demo, + Language, + Module, + ModuleResources, + Resource, + sequelize, +} from '../models'; +import { createModule, getModule, updateModule } from './module'; + +jest.mock('../models', () => ({ + Demo: { findAll: jest.fn() }, + Language: { findOrCreate: jest.fn() }, + Module: { + associations: { resources: 'resources' }, + create: jest.fn(), + findByPk: jest.fn(), + }, + ModuleResources: { + bulkCreate: jest.fn(), + destroy: jest.fn(), + }, + Resource: {}, + sequelize: { + transaction: jest.fn(async ( + callback: (transaction: object) => Promise, + ) => callback({})), + }, +})); + +const resource = (uid: number) => ({ + get: () => ({ + uid, + title: `Resource ${uid}`, + url: `https://example.com/${uid}`, + source: 'Example', + likes: 0, + }), +}); + +const makeModule = () => ({ + uid: 12, + topic: 'operation', + language: { name: 'cpp' }, + demos: [], + resources: [resource(3), resource(1)], + setDemos: jest.fn(), + setLanguage: jest.fn(), + reload: jest.fn(), + save: jest.fn(), +}); + +describe('module resource ordering', () => { + const transaction = {}; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(Language.findOrCreate).mockResolvedValue([{ uid: 4 }] as never); + jest.mocked(Demo.findAll).mockResolvedValue([]); + }); + + it('persists submitted resource order when creating a module', async () => { + const module = makeModule(); + jest.mocked(Module.create).mockResolvedValue(module as never); + + const created = await createModule('operation', 'cpp', [3, 1]); + + expect(ModuleResources.bulkCreate).toHaveBeenCalledWith( + [ + { moduleUid: 12, resourceUid: 3, ordering: 0 }, + { moduleUid: 12, resourceUid: 1, ordering: 1 }, + ], + { transaction }, + ); + expect(module.reload).toHaveBeenCalledWith(expect.objectContaining({ + order: [['resources', ModuleResources, 'ordering', 'ASC']], + })); + expect(created.resources.map(({ uid }) => uid)).toEqual([3, 1]); + }); + + it('replaces persisted resource order when updating a module', async () => { + const module = makeModule(); + jest.mocked(Module.findByPk).mockResolvedValue(module as never); + + const updated = await updateModule(12, undefined, undefined, [3, 1]); + + expect(ModuleResources.destroy).toHaveBeenCalledWith({ + where: { moduleUid: 12 }, + transaction, + }); + expect(ModuleResources.bulkCreate).toHaveBeenCalledWith( + [ + { moduleUid: 12, resourceUid: 3, ordering: 0 }, + { moduleUid: 12, resourceUid: 1, ordering: 1 }, + ], + { transaction }, + ); + expect(updated.resources.map(({ uid }) => uid)).toEqual([3, 1]); + }); + + it('requests persisted resource order when retrieving a module', async () => { + const module = makeModule(); + jest.mocked(Module.findByPk).mockResolvedValue(module as never); + + const retrieved = await getModule(12); + + expect(Module.findByPk).toHaveBeenCalledWith(12, expect.objectContaining({ + order: [['resources', ModuleResources, 'ordering', 'ASC']], + })); + expect(retrieved?.resources.map(({ uid }) => uid)).toEqual([3, 1]); + }); +}); diff --git a/codewit/api/src/routes/demo.spec.ts b/codewit/api/src/routes/demo.spec.ts new file mode 100644 index 00000000..9d02b02f --- /dev/null +++ b/codewit/api/src/routes/demo.spec.ts @@ -0,0 +1,85 @@ +import 'passport'; +import { Attempt, Demo, DemoExercises } from '../models'; +import { getDemoAttempt } from './demo'; + +jest.mock('../models', () => ({ + Attempt: { findAll: jest.fn() }, + Demo: { + associations: { exercises: 'exercises' }, + findByPk: jest.fn(), + }, + DemoExercises: 'DemoExercises', + DemoTags: 'DemoTags', + Language: 'Language', + Tag: 'Tag', + UserExerciseCompletion: 'UserExerciseCompletion', + User: {}, + sequelize: { query: jest.fn(), transaction: jest.fn() }, +})); + +describe('GET /demos/:uid/attempt exercise ordering', () => { + it('returns exercises in their persisted DemoExercises order', async () => { + jest.mocked(Demo.findByPk).mockImplementation(async (_uid, options) => { + const order = options?.order as unknown[]; + const hasExerciseOrder = order.some(entry => + Array.isArray(entry) + && entry[0] === 'exercises' + && entry[1] === DemoExercises + && entry[2] === 'order' + && entry[3] === 'ASC' + ); + const exercises = [ + { + uid: 1, + prompt: 'First', + language: { name: 'cpp' }, + starterCode: '', + DemoExercises: { order: 1 }, + }, + { + uid: 2, + prompt: 'Second', + language: { name: 'cpp' }, + starterCode: '', + DemoExercises: { order: 0 }, + }, + ]; + + return { + uid: 9, + title: 'Ordered demo', + topic: 'operation', + language: { name: 'cpp' }, + youtube_id: 'video', + youtube_thumbnail: 'thumbnail', + tags: [], + exercises: hasExerciseOrder + ? exercises.sort((left, right) => + left.DemoExercises.order - right.DemoExercises.order + ) + : exercises, + hasLikedBy: jest.fn().mockResolvedValue(false), + } as never; + }); + jest.mocked(Attempt.findAll).mockResolvedValue([]); + const json = jest.fn(); + const response = { + json, + status: jest.fn().mockReturnThis(), + }; + + await getDemoAttempt( + { + params: { uid: '9' }, + query: {}, + user: { uid: 4 }, + } as never, + response as never, + jest.fn(), + ); + + expect(json.mock.calls[0][0].demo.exercises.map( + (exercise: { uid: number }) => exercise.uid + )).toEqual([2, 1]); + }); +}); diff --git a/codewit/api/src/routes/demo.ts b/codewit/api/src/routes/demo.ts index 4c517529..47354681 100644 --- a/codewit/api/src/routes/demo.ts +++ b/codewit/api/src/routes/demo.ts @@ -61,7 +61,7 @@ function parse_non_zero_int(given: string): number | null { return parsed; } -demoRouter.get("/:uid/attempt", asyncHandle(async (req, res) => { +export const getDemoAttempt = asyncHandle(async (req, res) => { let maybe_module_id = typeof req.query.module_id === "string" ? parse_non_zero_int(req.query.module_id) : null; @@ -244,7 +244,9 @@ demoRouter.get("/:uid/attempt", asyncHandle(async (req, res) => { resources, related_demos, } as DemoAttempt); -})); +}); + +demoRouter.get("/:uid/attempt", getDemoAttempt); demoRouter.post('/', checkAdmin, async (req, res) => { try { diff --git a/codewit/client-e2e/src/e2e/app.cy.ts b/codewit/client-e2e/src/e2e/app.cy.ts index 6e33e6fa..fe56a14c 100644 --- a/codewit/client-e2e/src/e2e/app.cy.ts +++ b/codewit/client-e2e/src/e2e/app.cy.ts @@ -609,11 +609,11 @@ describe("Course creations functionality", () => { describe("Module creations functionality", () => { beforeEach(() => { mockAdminUser(); - cy.intercept('GET', '/modules', { + cy.intercept('GET', '/api/modules', { statusCode: 200, body: [] }).as('getModules'); - cy.intercept('GET', '/resources', { + cy.intercept('GET', '/api/resources', { statusCode: 200, body: [ { uid: 1, title: 'First resource', url: 'https://example.com/first', source: 'Example', likes: 0 }, @@ -636,7 +636,7 @@ describe("Module creations functionality", () => { }) it("should allow a user to creat a new moduele", () => { - cy.intercept('POST', '/modules', (req) => { + cy.intercept('POST', '/api/modules', (req) => { expect(req.body).to.deep.equal({ language: "cpp", resources: [], @@ -651,14 +651,10 @@ describe("Module creations functionality", () => { cy.wait('@createModule'); }) - it('adds, reorders, and removes selected resources before saving', () => { - cy.intercept('POST', '/modules', (req) => { - expect(req.body).to.deep.equal({ - language: 'cpp', - resources: [2], - topic: 'operation', - }); - }).as('createOrderedModule'); + it('submits selected resources in their reordered sequence', () => { + cy.intercept('POST', '/api/modules', (req) => { + expect(req.body.resources).to.deep.equal([2, 1]); + }).as('createReorderedModule'); cy.contains('Create Module').click(); getTopicSelect().type('operation{enter}'); @@ -668,11 +664,26 @@ describe("Module creations functionality", () => { cy.get('[data-testid="selected-resources"]').should('contain.text', 'First resource'); cy.get('[data-testid="selected-resources"]').should('contain.text', 'Second resource'); - cy.get('[aria-label="Drag Second resource"]').focus().type('{space}{uparrow}{space}'); + cy.get('[aria-label="Drag Second resource"]').focus().type('{enter}{uparrow}{enter}'); + + getSubmitButton().click(); + cy.wait('@createReorderedModule'); + }); + + it('removes a selected resource before saving', () => { + cy.intercept('POST', '/api/modules', (req) => { + expect(req.body.resources).to.deep.equal([2]); + }).as('createModuleWithoutRemovedResource'); + + cy.contains('Create Module').click(); + getTopicSelect().type('operation{enter}'); + getLanguageSelect().type('cpp{enter}'); + cy.get('#resource-select').type('First resource{enter}'); + cy.get('#resource-select').type('Second resource{enter}'); cy.get('[aria-label="Remove First resource"]').click(); getSubmitButton().click(); - cy.wait('@createOrderedModule'); + cy.wait('@createModuleWithoutRemovedResource'); }); }) @@ -680,7 +691,7 @@ describe("Module creations functionality", () => { describe('Demo creation functionality', () => { beforeEach(() => { mockAdminUser(); - cy.intercept('GET', '/exercises', { + cy.intercept('GET', '/api/exercises', { statusCode: 200, body: [{ uid: 1, @@ -707,7 +718,7 @@ describe('Demo creation functionality', () => { }); it('allows a user to create a new demo', () => { - cy.intercept('POST', '/demos', (req) => { + cy.intercept('POST', '/api/demos', (req) => { expect(req.body).to.deep.equal({ title: 'New Demo Title', youtube_id: '8bc-VU3V7lU', @@ -750,7 +761,7 @@ describe('Demo creation functionality', () => { describe('Demo Editing/Deleting functionality', () => { beforeEach(() => { mockAdminUser(); - cy.intercept('GET', '/demos', { + cy.intercept('GET', '/api/demos', { statusCode: 200, body: [ { @@ -761,23 +772,34 @@ describe('Demo Editing/Deleting functionality', () => { topic: 'operation', language: 'cpp', tags: ['console io'], - exercises: [1], + exercises: [ + { uid: 1, prompt: 'First Exercise Prompt' }, + { uid: 2, prompt: 'Second Exercise Prompt' }, + ], }, ], }).as('getDemos'); - cy.intercept('GET', '/exercises', { + cy.intercept('GET', '/api/exercises', { statusCode: 200, - body: [{ - uid: 1, - prompt: 'New Exercise Prompt', - referenceTest: 'console.log("Hello World");', - tags: ['console io', 'customtag'], - topic: 'console io', - language: { - name: 'cpp' - } - }] + body: [ + { + uid: 1, + prompt: 'First Exercise Prompt', + referenceTest: 'console.log("First");', + tags: ['console io', 'customtag'], + topic: 'console io', + language: { name: 'cpp' }, + }, + { + uid: 2, + prompt: 'Second Exercise Prompt', + referenceTest: 'console.log("Second");', + tags: ['console io'], + topic: 'console io', + language: { name: 'cpp' }, + }, + ] }).as('getExercise'); cy.visit('/create/demo'); @@ -801,7 +823,7 @@ describe('Demo Editing/Deleting functionality', () => { }); it('edit should send updated demo and verify body', () => { - cy.intercept('PATCH', '/demos/99', (req) => { + cy.intercept('PATCH', '/api/demos/99', (req) => { expect(req.body).to.deep.equal({ uid: 99, title: 'Updated Demo Title', @@ -810,7 +832,7 @@ describe('Demo Editing/Deleting functionality', () => { topic: 'operation', language: 'cpp', tags: ['console io', 'updated tag'], - exercises: [1], + exercises: [1, 2], }); }).as('patchDemo'); @@ -821,14 +843,55 @@ describe('Demo Editing/Deleting functionality', () => { cy.wait('@patchDemo'); }); + it('saves and restores reordered exercises', () => { + cy.intercept('PATCH', '/api/demos/99', (req) => { + expect(req.body.exercises).to.deep.equal([2, 1]); + req.reply({ + ...req.body, + exercises: [ + { uid: 2, prompt: 'Second Exercise Prompt' }, + { uid: 1, prompt: 'First Exercise Prompt' }, + ], + }); + }).as('reorderDemoExercises'); + cy.intercept('GET', '/api/demos', { + statusCode: 200, + body: [{ + uid: 99, + title: 'New Demo Title', + youtube_id: '8bc-VU3V7lU', + youtube_thumbnail: 'https://i.ytimg.com/vi/8bc-VU3V7lU/hqdefault.jpg', + topic: 'operation', + language: 'cpp', + tags: ['console io'], + exercises: [ + { uid: 2, prompt: 'Second Exercise Prompt' }, + { uid: 1, prompt: 'First Exercise Prompt' }, + ], + }], + }).as('getReorderedDemos'); + + cy.contains('Edit').click(); + cy.get('[aria-label="Drag Second Exercise Prompt"]').focus().type('{enter}{uparrow}{enter}'); + cy.contains('button', 'Update').click(); + cy.wait('@reorderDemoExercises'); + cy.wait('@getReorderedDemos'); + + cy.contains('Edit').click(); + cy.get('[aria-label^="Drag"]').then(handles => { + expect(handles.eq(0)).to.have.attr('aria-label', 'Drag Second Exercise Prompt'); + expect(handles.eq(1)).to.have.attr('aria-label', 'Drag First Exercise Prompt'); + }); + }); + it('delete should call endpoint with correct UID', () => { - cy.intercept('DELETE', '/demos/99').as('deleteDemo'); + cy.intercept('DELETE', '/api/demos/99').as('deleteDemo'); cy.contains('Delete').click(); cy.wait('@deleteDemo').its('request.url').should('include', '/demos/99'); }); it('edit should update the title visually on table', () => { - cy.intercept('PATCH', '/demos/99', { + cy.intercept('PATCH', '/api/demos/99', { statusCode: 200, body: { uid: 99, diff --git a/codewit/client-e2e/src/support/app.po.ts b/codewit/client-e2e/src/support/app.po.ts index 52fcabda..c9d52d22 100644 --- a/codewit/client-e2e/src/support/app.po.ts +++ b/codewit/client-e2e/src/support/app.po.ts @@ -30,7 +30,7 @@ export const getCheckList = () => cy.get('[data-testid="check-list"]'); export const getHomeModule = () => cy.get('[data-testid="module"]'); export const mockNonAdminUser = () => { - cy.intercept('GET', '/oauth2/google/userinfo', { + cy.intercept('GET', '/api/oauth2/google/userInfo', { statusCode: 200, body: { user: { @@ -44,7 +44,7 @@ export const mockNonAdminUser = () => { }).as('getUserInfo'); }; export const mockAdminUser = () => { - cy.intercept('GET', '/oauth2/google/userinfo', { + cy.intercept('GET', '/api/oauth2/google/userInfo', { statusCode: 200, body: { user: { diff --git a/codewit/client/src/pages/DemoForm.tsx b/codewit/client/src/pages/DemoForm.tsx index edb45171..f903f19d 100644 --- a/codewit/client/src/pages/DemoForm.tsx +++ b/codewit/client/src/pages/DemoForm.tsx @@ -37,6 +37,7 @@ import { language_options, get_language_option } from "../components/form/Langua import CreateButton from "../components/form/CreateButton"; import ReusableTable, { Column } from "../components/form/ReusableTable"; import { VideoOption, use_yt_videos } from "../hooks/yt_videos"; +import { moveArrayItem } from "../utils/arrayOrder"; interface DemoForm { uid?: number, @@ -387,10 +388,7 @@ function DemoForm({view, demo, on_cancel, on_created, on_updated}: DemoFormProps { - let next = [...field.state.value]; - next.splice(a_index, 0, next.splice(b_index, 1)[0]); - - field.setValue(next); + field.setValue(moveArrayItem(field.state.value, a_index, b_index)); }} on_remove={(index, uid) => { let next = [...field.state.value]; @@ -554,9 +552,15 @@ function SortableExerciseItem({uid, prompt, on_remove}: SortableExerciseItemProp className="border rounded-lg p-2 gap-x-2 flex flex-row items-center bg-[rgb(55,65,81)] border-[rgb(75,85,99)]" style={style} > -
+
+

{prompt}

uid: {uid} diff --git a/codewit/client/src/utils/arrayOrder.spec.ts b/codewit/client/src/utils/arrayOrder.spec.ts new file mode 100644 index 00000000..d340d3f7 --- /dev/null +++ b/codewit/client/src/utils/arrayOrder.spec.ts @@ -0,0 +1,10 @@ +import { moveArrayItem } from './arrayOrder'; + +describe('moveArrayItem', () => { + it('moves the dragged item to the target position without mutating the input', () => { + const original = ['first', 'second', 'third']; + + expect(moveArrayItem(original, 2, 0)).toEqual(['third', 'first', 'second']); + expect(original).toEqual(['first', 'second', 'third']); + }); +}); diff --git a/codewit/client/src/utils/arrayOrder.ts b/codewit/client/src/utils/arrayOrder.ts new file mode 100644 index 00000000..6ffecc6f --- /dev/null +++ b/codewit/client/src/utils/arrayOrder.ts @@ -0,0 +1,11 @@ +export function moveArrayItem( + values: readonly T[], + fromIndex: number, + toIndex: number, +): T[] { + const reordered = [...values]; + const [item] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, item); + + return reordered; +}