diff --git a/.travis.yml b/.travis.yml index 478ebd45..d576bb7e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,9 +15,9 @@ jobs: - "./mvnw clean package -B -q" after_script: - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin - - docker build -t vscode4teaching/vscode4teaching:2.0.1 . + - docker build -t vscode4teaching/vscode4teaching:2.0.2 . - docker build -t vscode4teaching/vscode4teaching:latest . - - docker push vscode4teaching/vscode4teaching:2.0.1 + - docker push vscode4teaching/vscode4teaching:2.0.2 - docker push vscode4teaching/vscode4teaching:latest - language: node_js os: diff --git a/vscode4teaching-extension/package-lock.json b/vscode4teaching-extension/package-lock.json index f0822903..c6bfc324 100644 --- a/vscode4teaching-extension/package-lock.json +++ b/vscode4teaching-extension/package-lock.json @@ -1,18 +1,19 @@ { "name": "vscode4teaching", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode4teaching", - "version": "2.0.1", + "version": "2.0.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "axios": "^0.21.1", "form-data": "^3.0.0", "ignore": "^5.1.6", "jszip": "^3.4.0", + "lodash.escaperegexp": "^4.1.2", "mkdirp": "^1.0.4", "vsls": "^1.0.3015", "ws": "^7.4.6" @@ -24,6 +25,7 @@ "@types/glob": "^7.1.1", "@types/jest": "^25.2.3", "@types/jest-cli": "^24.3.0", + "@types/lodash.escaperegexp": "^4.1.6", "@types/mkdirp": "^1.0.0", "@types/node": "^10.15.1", "@types/rimraf": "^3.0.0", @@ -42,7 +44,7 @@ "vscode-test": "^1.3.0" }, "engines": { - "vscode": "^1.45.1" + "vscode": "^1.61.0" } }, "node_modules/@babel/code-frame": { @@ -2443,6 +2445,21 @@ "node": ">= 8.3" } }, + "node_modules/@types/lodash": { + "version": "4.14.177", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.177.tgz", + "integrity": "sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw==", + "dev": true + }, + "node_modules/@types/lodash.escaperegexp": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/lodash.escaperegexp/-/lodash.escaperegexp-4.1.6.tgz", + "integrity": "sha512-uENiqxLlqh6RzeE1cC6Z2gHqakToN9vKlTVCFkSVjAfeMeh2fY0916tHwJHeeKs28qB/hGYvKuampGYH5QDVCw==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -7914,6 +7931,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9594,15 +9616,6 @@ "source-map": "^0.6.0" } }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-url": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", @@ -10144,15 +10157,6 @@ "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev" } }, - "node_modules/tslint/node_modules/diff": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.1.tgz", - "integrity": "sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/tslint/node_modules/mkdirp": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.3.tgz", @@ -10166,12 +10170,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/tslint/node_modules/mkdirp/node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - }, "node_modules/tsutils": { "version": "2.29.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", @@ -12778,6 +12776,21 @@ "jest-cli": "*" } }, + "@types/lodash": { + "version": "4.14.177", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.177.tgz", + "integrity": "sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw==", + "dev": true + }, + "@types/lodash.escaperegexp": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/lodash.escaperegexp/-/lodash.escaperegexp-4.1.6.tgz", + "integrity": "sha512-uENiqxLlqh6RzeE1cC6Z2gHqakToN9vKlTVCFkSVjAfeMeh2fY0916tHwJHeeKs28qB/hGYvKuampGYH5QDVCw==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -17131,6 +17144,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -18487,14 +18505,6 @@ "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } } }, "source-map-url": { @@ -18917,12 +18927,6 @@ "tsutils": "^2.29.0" }, "dependencies": { - "diff": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.1.tgz", - "integrity": "sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==", - "dev": true - }, "mkdirp": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.3.tgz", @@ -18930,14 +18934,6 @@ "dev": true, "requires": { "minimist": "^1.2.5" - }, - "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - } } } } diff --git a/vscode4teaching-extension/package.json b/vscode4teaching-extension/package.json index 1da085b5..958da80a 100644 --- a/vscode4teaching-extension/package.json +++ b/vscode4teaching-extension/package.json @@ -12,9 +12,9 @@ }, "displayName": "VS Code 4 Teaching", "description": "Bring the programming exercises directly to the student’s editor.", - "version": "2.0.1", + "version": "2.0.2", "engines": { - "vscode": "^1.45.1" + "vscode": "^1.61.0" }, "categories": [ "Other" @@ -310,6 +310,7 @@ "@types/glob": "^7.1.1", "@types/jest": "^25.2.3", "@types/jest-cli": "^24.3.0", + "@types/lodash.escaperegexp": "^4.1.6", "@types/mkdirp": "^1.0.0", "@types/node": "^10.15.1", "@types/rimraf": "^3.0.0", @@ -332,6 +333,7 @@ "form-data": "^3.0.0", "ignore": "^5.1.6", "jszip": "^3.4.0", + "lodash.escaperegexp": "^4.1.2", "mkdirp": "^1.0.4", "vsls": "^1.0.3015", "ws": "^7.4.6" diff --git a/vscode4teaching-extension/resources/dashboard/dashboard.js b/vscode4teaching-extension/resources/dashboard/dashboard.js index f551c65b..4172f5f4 100644 --- a/vscode4teaching-extension/resources/dashboard/dashboard.js +++ b/vscode4teaching-extension/resources/dashboard/dashboard.js @@ -18,29 +18,42 @@ window.addEventListener('message', event => { const message = event.data; - for (const key in message) { - document.getElementById(key).textContent = message[key] + switch (message.type) { + case 'updateDate': + for (const key in message.update) { + document.getElementById(key).textContent = message.update[key] + } + break; + case 'openDone': + document.querySelectorAll(".button-col > button").forEach((e) => { + e.disabled = false; + }); + break; } }) document.querySelectorAll(".workspace-link").forEach((row) => { row.addEventListener("click", () => { + document.querySelectorAll(".button-col > button").forEach((e) => { + e.disabled = true; + }); const username = Array.from(row.parentElement.parentElement.children).find(e => e.classList.contains('username')).innerHTML; vscode.postMessage({ type: "goToWorkspace", username: username, - lastMod:row.attributes.getNamedItem("data-lastMod").value, }); }); }); document.querySelectorAll(".workspace-link-diff").forEach((row) => { row.addEventListener("click", () => { + Array.from(row.parentElement.children).forEach((e) => { + e.disabled = true; + }); const username = Array.from(row.parentElement.parentElement.children).find(e => e.classList.contains('username')).innerHTML; vscode.postMessage({ type: "diff", username: username, - lastMod:row.attributes.getNamedItem("data-lastMod-diff").value, }); }); }); diff --git a/vscode4teaching-extension/src/client/APIClient.ts b/vscode4teaching-extension/src/client/APIClient.ts index d56168ae..8e5945d9 100644 --- a/vscode4teaching-extension/src/client/APIClient.ts +++ b/vscode4teaching-extension/src/client/APIClient.ts @@ -99,8 +99,10 @@ class APIClientSingleton { console.error(error); if (axios.isCancel(error)) { vscode.window.showErrorMessage("Request timeout."); - APIClientSession.invalidateSession(); + // APIClientSession.invalidateSession(); } else if (error.response) { + console.log(error.response); + console.log(error.request); if (error.response.status === 401 && !APIClient.error401thrown) { vscode.window.showWarningMessage("It seems that we couldn't log in, please log in."); APIClient.error401thrown = true; @@ -118,14 +120,13 @@ class APIClientSingleton { vscode.window.showErrorMessage("Error " + error.response.status + ". " + msg); APIClient.error401thrown = false; APIClient.error403thrown = false; - APIClientSession.invalidateSession(); + // APIClientSession.invalidateSession(); } } else if (error.request) { vscode.window.showErrorMessage("Can't connect to the server. " + error.message); APIClientSession.invalidateSession(); } else { vscode.window.showErrorMessage(error.message); - APIClientSession.invalidateSession(); } } @@ -159,7 +160,7 @@ class APIClientSingleton { method: "GET", responseType: "arraybuffer", }; - return APIClient.createRequest(options, "Downloading exercise files..."); + return APIClient.createRequest(options, "Downloading exercise files...", true); } public addCourse(data: CourseEdit): AxiosPromise { @@ -229,7 +230,7 @@ class APIClientSingleton { responseType: "json", data: dataForm, }; - return APIClient.createRequest(options, "Uploading template..."); + return APIClient.createRequest(options, "Uploading template...", true); } public deleteExercise(id: number): AxiosPromise { @@ -297,7 +298,7 @@ class APIClientSingleton { responseType: "json", data: dataForm, }; - return APIClient.createRequest(options, "Uploading files..."); + return APIClient.createRequest(options, "Uploading files...", true); } public getAllStudentFiles(exerciseId: number): AxiosPromise { @@ -306,7 +307,7 @@ class APIClientSingleton { method: "GET", responseType: "arraybuffer", }; - return APIClient.createRequest(options, "Downloading student files..."); + return APIClient.createRequest(options, "Downloading student files...", true); } public getTemplate(exerciseId: number): AxiosPromise { @@ -315,7 +316,7 @@ class APIClientSingleton { method: "GET", responseType: "arraybuffer", }; - return APIClient.createRequest(options, "Downloading exercise template..."); + return APIClient.createRequest(options, "Downloading exercise template...", true); } public getFilesInfo(username: string, exerciseId: number): AxiosPromise { @@ -397,14 +398,14 @@ class APIClientSingleton { return APIClient.createRequest(options, "Fetching exercise info for current user..."); } - public updateExerciseUserInfo(exerciseId: number, status: number, lastModifiedFile?: string): AxiosPromise { + public updateExerciseUserInfo(exerciseId: number, status: number, modifiedFiles?: string[]): AxiosPromise { const options: AxiosBuildOptions = { url: "/api/exercises/" + exerciseId + "/info", method: "PUT", responseType: "json", data: { status, - lastModifiedFile, + modifiedFiles, }, }; return APIClient.createRequest(options, "Updating exercise user info..."); @@ -416,7 +417,7 @@ class APIClientSingleton { method: "GET", responseType: "json", }; - return APIClient.createRequest(options, "Fetching students' exercise user info..."); + return APIClient.createRequest(options, "Fetching students' exercise user info...", true); } private signUp(credentials: UserSignup): AxiosPromise { @@ -444,10 +445,18 @@ class APIClientSingleton { * @param options Options from to build axios request * @param statusMessage message to add to the vscode status bar */ - private createRequest(options: AxiosBuildOptions, statusMessage: string): AxiosPromise { + private createRequest(options: AxiosBuildOptions, statusMessage: string, notification: boolean = false): AxiosPromise { const axiosOptions = APIClientSession.buildOptions(options); const thenable = axios(axiosOptions.axiosOptions); - vscode.window.setStatusBarMessage(statusMessage, thenable); + if (notification) { + vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + cancellable: false, + title: statusMessage, + }, (progress, token) => thenable); + } else { + vscode.window.setStatusBarMessage("$(sync~spin) " + statusMessage, thenable); + } return thenable.then((result) => { if (axiosOptions.timeout) { clearTimeout(axiosOptions.timeout); diff --git a/vscode4teaching-extension/src/components/courses/CoursesTreeProvider.ts b/vscode4teaching-extension/src/components/courses/CoursesTreeProvider.ts index 190a9d05..8db7279d 100644 --- a/vscode4teaching-extension/src/components/courses/CoursesTreeProvider.ts +++ b/vscode4teaching-extension/src/components/courses/CoursesTreeProvider.ts @@ -1,6 +1,5 @@ import * as vscode from "vscode"; import { APIClient } from "../../client/APIClient"; -import { APIClientSession } from "../../client/APIClientSession"; import { CurrentUser } from "../../client/CurrentUser"; import { Course, instanceOfCourse } from "../../model/serverModel/course/Course"; import { instanceOfExercise } from "../../model/serverModel/exercise/Exercise"; @@ -253,6 +252,8 @@ export class CoursesProvider implements vscode.TreeDataProvider { // Create zip file from files and send them const course: Course = item.item; try { + this.loading = true; + CoursesProvider.triggerTreeReload(); const addExerciseData = await APIClient.addExercise(course.id, { name }); console.debug(addExerciseData); try { @@ -260,7 +261,6 @@ export class CoursesProvider implements vscode.TreeDataProvider { const zipContent = await FileZipUtil.getZipFromUris(fileUris); const response = await APIClient.uploadExerciseTemplate(addExerciseData.data.id, zipContent); console.debug(response); - CoursesProvider.triggerTreeReload(item); } catch (uploadError) { try { // If upload fails delete the exercise and show error @@ -270,6 +270,9 @@ export class CoursesProvider implements vscode.TreeDataProvider { } catch (deleteError) { APIClient.handleAxiosError(deleteError); } + } finally { + this.loading = false; + CoursesProvider.triggerTreeReload(); } } catch (error) { APIClient.handleAxiosError(error); @@ -389,7 +392,7 @@ export class CoursesProvider implements vscode.TreeDataProvider { const code = await this.getInput("Introduce sharing code", Validators.validateSharingCode); if (code) { try { - const response = await APIClient.getCourseWithCode(code); + const response = await APIClient.getCourseWithCode(code.trim()); console.debug(response); const course: Course = response.data; CurrentUser.addNewCourse(course); @@ -399,6 +402,12 @@ export class CoursesProvider implements vscode.TreeDataProvider { } } } + + public changeLoading(loading: boolean) { + this.loading = loading; + CoursesProvider.triggerTreeReload(); + } + /** * Create exercise buttons from exercises. * @param element course diff --git a/vscode4teaching-extension/src/components/dashboard/DashboardWebview.ts b/vscode4teaching-extension/src/components/dashboard/DashboardWebview.ts index 6a867de2..241491a9 100644 --- a/vscode4teaching-extension/src/components/dashboard/DashboardWebview.ts +++ b/vscode4teaching-extension/src/components/dashboard/DashboardWebview.ts @@ -6,6 +6,7 @@ import { WebSocketV4TConnection } from "../../client/WebSocketV4TConnection"; import { Course } from "../../model/serverModel/course/Course"; import { Exercise } from "../../model/serverModel/exercise/Exercise"; import { ExerciseUserInfo } from "../../model/serverModel/exercise/ExerciseUserInfo"; +import { OpenQuickPick } from "./OpenQuickPick"; export class DashboardWebview { public static currentPanel: DashboardWebview | undefined; @@ -71,7 +72,9 @@ export class DashboardWebview { // This happens when the user closes the panel or when the panel is closed programatically this.panel.onDidDispose(() => { global.clearInterval(this.lastUpdatedInterval); - // global.clearInterval(this._reloadInterval); + // if (this._reloadInterval !== undefined) { + // global.clearInterval(this._reloadInterval); + // } this.ws.close(); this.dispose(); }); @@ -105,29 +108,28 @@ export class DashboardWebview { // break; // } case "goToWorkspace": { - await vscode.commands.executeCommand("vscode4teaching.getstudentfiles", course.name, exercise).then(async () => { - const workspaces = vscode.workspace.workspaceFolders; - if (workspaces) { - const wsF = vscode.workspace.workspaceFolders?.find((e) => e.name === message.username); - if (wsF) { - const doc1 = await vscode.workspace.openTextDocument(await this.findLastModifiedFile(wsF, message.lastMod)); - // let doc1 = await vscode.workspace.openTextDocument(await this.findMainFile(wsF)); - await vscode.window.showTextDocument(doc1); - } + this.showQuickPick(message.username, course, exercise).then(async (filePath) => { + if (filePath !== undefined) { + const doc1 = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc1); } + this.panel.webview.postMessage({ type: "openDone", username: message.username }); + }).catch((err) => { + console.error(err); + vscode.window.showErrorMessage(err); }); break; } case "diff": { - await vscode.commands.executeCommand("vscode4teaching.getstudentfiles", course.name, exercise).then(async () => { - const workspaces = vscode.workspace.workspaceFolders; - if (workspaces) { - const wsF = vscode.workspace.workspaceFolders?.find((e) => e.name === message.username); - if (wsF) { - await vscode.commands.executeCommand("vscode4teaching.diff", await this.findLastModifiedFile(wsF, message.lastMod)); - } + this.showQuickPick(message.username, course, exercise).then(async (filePath) => { + if (filePath !== undefined) { + await vscode.commands.executeCommand("vscode4teaching.diff", filePath); } + this.panel.webview.postMessage({ type: "openDone", username: message.username }); + }).catch((err) => { + console.error(err); + vscode.window.showErrorMessage(err); }); break; } @@ -209,10 +211,10 @@ export class DashboardWebview { for (const eui of this._euis) { message["user-lastmod-" + eui.user.id] = this.getElapsedTime(eui.updateDateTime); } - this.panel.webview.postMessage(message); + this.panel.webview.postMessage({ type: "updateDate", update: message }); } - private async findLastModifiedFile(folder: vscode.WorkspaceFolder, fileRoute: string) { + private async findLastModifiedFile(folder: vscode.WorkspaceFolder, fileRoute: string): Promise<{ uri: vscode.Uri, relativePath: string }> { if (fileRoute === "null") { return this.findMainFile(folder); } const fileRegex = /^\/[^\/]+\/[^\/]+\/[^\/]+\/(.+)$/; @@ -220,24 +222,26 @@ export class DashboardWebview { if (regexResults && regexResults.length > 1) { const match: vscode.Uri[] = await vscode.workspace.findFiles(new vscode.RelativePattern(folder, regexResults[1])); if (match.length === 1) { - return match[0]; + return { uri: match[0], relativePath: regexResults[1] }; } } return this.findMainFile(folder); } - private async findMainFile(folder: vscode.WorkspaceFolder) { + private async findMainFile(folder: vscode.WorkspaceFolder): Promise<{ uri: vscode.Uri, relativePath: string }> { const patterns = ["readme.*", "readme", "Main.*", "main.*", "index.html", "*"]; let matches: vscode.Uri[] = []; let i = 0; + let pattern = ""; while (matches.length <= 0 && i < patterns.length) { - const file = new vscode.RelativePattern(folder, patterns[i++]); + pattern = patterns[i++]; + const file = new vscode.RelativePattern(folder, pattern); matches = (await vscode.workspace.findFiles(file)); } if (matches.length <= 0) { matches = (await vscode.workspace.findFiles(new vscode.RelativePattern(folder, "*"))); } - return matches[0]; + return { uri: matches[0], relativePath: pattern }; } private reloadData() { @@ -297,9 +301,9 @@ export class DashboardWebview { break; } } - rows = rows + ``; - const buttons = ``; - rows += eui.lastModifiedFile ? buttons : `Not found`; + rows = rows + ``; + const buttons = ``; + rows += buttons; rows = rows + `\n`; rows = rows + `${this.getElapsedTime(eui.updateDateTime)}\n`; rows = rows + "\n"; @@ -400,4 +404,116 @@ export class DashboardWebview { return `${Math.floor(elapsedTime)} ${unit}`; } + + /** + * Show quick pick with all modified files in EUIs for each user + * @param username string username + * @param course Course course + * @param exercise Exercise exercise + * @returns Thenable the selected file + */ + private async showQuickPick(username: string, course: Course, exercise: Exercise): Promise { + // Download most recent files + await vscode.commands.executeCommand("vscode4teaching.getstudentfiles", course.name, exercise); + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + cancellable: false, + title: "Getting modified files...", + }, (progress, token) => this.buildQuickPickItems(username)) + .then(async (result: OpenQuickPick[]) => { + if (result) { + const selection = await this.showQuickPickRecursive(result); + if (selection) { + return selection; + } + } + }); + } + + private async buildQuickPickItems(username: string): Promise { + // Find all modified files URIs (paths) + const workspaces = vscode.workspace.workspaceFolders; + if (workspaces) { + const wsF = workspaces.find((e) => e.name === username); + if (wsF) { + const euis = this._euis.filter((eui) => eui.user.username === username); + const uris: vscode.Uri[] = []; + const relativePaths: string[] = []; + if (euis.length > 0) { + const eui = euis[0]; + if (eui.modifiedFiles && eui.modifiedFiles.length > 0) { + for (const fileName of eui.modifiedFiles) { + const lastFile = await this.findLastModifiedFile(wsF, fileName); + if (lastFile.uri) { + relativePaths.push(lastFile.relativePath); + uris.push(lastFile.uri); + } + } + } else { + vscode.window.showWarningMessage(`No modified files for ${username}`); + } + } + if (uris.length > 0) { + // Create directory tree object from relative paths with relative path and children + // Result is array of objects with keys "name" with the name of the file and "children" if the file is a directory listing its children + const result: OpenQuickPick[] = []; + relativePaths.forEach((relPath, index) => { + result.push(new OpenQuickPick(relPath, [], undefined, false, uris[index])); + }); + return result; + } else { + throw new Error("No files found for student " + username); + } + } else { + throw new Error("No files found for student " + username); + } + } else { + throw new Error("The exercise's workspace is not open."); + } + } + + // Add parent object as property to children in the tree with key "children" as array of objects + private addParentsToChildren(parents: OpenQuickPick[], children: OpenQuickPick[]) { + children.forEach((child) => { + child.parents = parents; + if (child.children) { + this.addParentsToChildren(children, child.children); + } + }); + } + + // Combines parent and child as a single path if there is only one child and it is a directory + private shortenPaths(item: OpenQuickPick) { + if ((item.children.length === 1) && (item.children[0].children.length > 0)) { + const child = item.children[0]; + item.name = item.name + "/" + child.name; + item.children = child.children; + this.shortenPaths(item); + } else { + for (const child of item.children) { + this.shortenPaths(child); + } + } + } + + // Recursive show quick pick with files given the file or directory name and an array of children + private async showQuickPickRecursive(recursiveFiles: OpenQuickPick[]): Promise { + if (!recursiveFiles[0].isGoBackButton && recursiveFiles[0].parents && recursiveFiles[0].parents.length > 0) { + recursiveFiles.unshift(new OpenQuickPick("..", [], recursiveFiles[0].parents, true)); + } + const selection = await vscode.window.showQuickPick(recursiveFiles, { + placeHolder: "Choose a file to open", + }); + if (selection) { + if (selection.parents && selection.label === "..") { + return this.showQuickPickRecursive(selection.parents); + } + if (selection.children && selection.children.length > 0) { + return this.showQuickPickRecursive(selection.children); + } else { + console.log(selection); + return selection.fullPath; + } + } + } } diff --git a/vscode4teaching-extension/src/components/dashboard/OpenQuickPick.ts b/vscode4teaching-extension/src/components/dashboard/OpenQuickPick.ts new file mode 100644 index 00000000..18485235 --- /dev/null +++ b/vscode4teaching-extension/src/components/dashboard/OpenQuickPick.ts @@ -0,0 +1,28 @@ +import * as vscode from "vscode"; + +export class OpenQuickPick implements vscode.QuickPickItem { + + constructor( + public name: string, + public children: OpenQuickPick[], + public parents?: OpenQuickPick[], + public isGoBackButton: boolean = false, + public fullPath?: vscode.Uri, + public description?: string, + public detail?: string, + public picked?: boolean, + public alwaysShow?: boolean, + ) { + + } + + get label(): string { + let prefix = ""; + if (this.children.length > 0) { + prefix = "$(folder) "; + } else if (!this.isGoBackButton) { + prefix = "$(file) "; + } + return prefix + this.name; + } +} diff --git a/vscode4teaching-extension/src/extension.ts b/vscode4teaching-extension/src/extension.ts index 466699d7..9b375fa2 100644 --- a/vscode4teaching-extension/src/extension.ts +++ b/vscode4teaching-extension/src/extension.ts @@ -42,10 +42,10 @@ export let changeEvent: vscode.Disposable; export let createEvent: vscode.Disposable; export let deleteEvent: vscode.Disposable; export let commentInterval: NodeJS.Timeout; +export let uploadTimeout: NodeJS.Timeout | undefined; export let wsLiveshare: WebSocketV4TConnection | undefined; export let liveshareService: LiveShareService | undefined; -// TODO: Comments not working export function activate(context: vscode.ExtensionContext) { vscode.window.registerTreeDataProvider("vscode4teachingview", coursesProvider); const sessionInitialized = APIClient.initializeSessionFromFile(); @@ -96,11 +96,21 @@ export function activate(context: vscode.ExtensionContext) { }); const getFilesDisposable = vscode.commands.registerCommand("vscode4teaching.getexercisefiles", async (courseName: string, exercise: Exercise) => { - await getSingleStudentExerciseFiles(courseName, exercise); + coursesProvider.changeLoading(true); + try { + await getSingleStudentExerciseFiles(courseName, exercise); + } finally { + coursesProvider.changeLoading(false); + } }); const getStudentFiles = vscode.commands.registerCommand("vscode4teaching.getstudentfiles", async (courseName: string, exercise: Exercise) => { - await getMultipleStudentExerciseFiles(courseName, exercise); + coursesProvider.changeLoading(true); + try { + await getMultipleStudentExerciseFiles(courseName, exercise); + } finally { + coursesProvider.changeLoading(false); + } }); const addCourseDisposable = vscode.commands.registerCommand("vscode4teaching.addcourse", () => { @@ -151,7 +161,7 @@ export function activate(context: vscode.ExtensionContext) { const templateFile = path.resolve(templates[parentDir], relativePath); if (fs.existsSync(templateFile)) { const templateFileUri = vscode.Uri.file(templateFile); - vscode.commands.executeCommand("vscode.diff", file, templateFileUri); + vscode.commands.executeCommand("vscode.diff", templateFileUri, file); } else { vscode.window.showErrorMessage("File doesn't exist in the template."); } @@ -223,20 +233,26 @@ export function activate(context: vscode.ExtensionContext) { const finishExercise = vscode.commands.registerCommand("vscode4teaching.finishexercise", async () => { const warnMessage = "Finish exercise? Exercise will be marked as finished and you will not be able to upload any more updates"; const selectedOption = await vscode.window.showWarningMessage(warnMessage, { modal: true }, "Accept"); - if (selectedOption === "Accept" && finishItem) { - const response = await APIClient.updateExerciseUserInfo(finishItem.getExerciseId(), 1); - console.debug(response); - if (response.data.status === 1 && finishItem) { - finishItem.dispose(); - if (changeEvent) { - changeEvent.dispose(); - } - if (createEvent) { - createEvent.dispose(); - } - if (deleteEvent) { - deleteEvent.dispose(); + if ((selectedOption === "Accept") && finishItem) { + try { + const response = await APIClient.updateExerciseUserInfo(finishItem.getExerciseId(), 1); + console.debug(response); + if ((response.data.status === 1) && finishItem) { + finishItem.dispose(); + if (changeEvent) { + changeEvent.dispose(); + } + if (createEvent) { + createEvent.dispose(); + } + if (deleteEvent) { + deleteEvent.dispose(); + } + } else { + vscode.window.showErrorMessage("An unexpected error has occurred. The exercise has not been marked as finished. Please try again."); } + } catch (error) { + APIClient.handleAxiosError(error); } } }); @@ -273,11 +289,23 @@ export function activate(context: vscode.ExtensionContext) { deleteCourseDisposable, refreshView, refreshCourse, addExercise, editExercise, deleteExercise, addUsersToCourse, removeUsersFromCourse, getStudentFiles, diff, createComment, share, signup, signupTeacher, getWithCode, finishExercise, showDashboard, showLiveshareBoard); - initializeLiveShare().then(() => { - console.log("LiveShare initialized"); - console.log(liveshareService); - console.log(wsLiveshare); - }); + // Temp fix for this issue https://github.com/microsoft/vscode/issues/136787 + // TODO: Remove this when the issue is fixed + const isWin = process.platform === "win32"; + if (isWin) { + if (vscode.workspace.getConfiguration("http").get("systemCertificates")) { + vscode.window.showWarningMessage("There may be issues connecting to the server unless you change your configuration settings.\nClicking the button will automatically make all configuration changes needed.", "Change configuration and restart").then((selected) => { + if (selected) { + vscode.workspace.getConfiguration("http").update("systemCertificates", false, true).then(() => { + vscode.commands.executeCommand("workbench.action.reloadWindow"); + }, (error) => { + console.error(error); + vscode.window.showErrorMessage("There was an error updating your configuration: " + error); + }); + } + }); + } + } } export function deactivate() { @@ -304,7 +332,7 @@ export function disableFeatures() { global.clearInterval(commentInterval); } -export async function initializeExtension(cwds: ReadonlyArray) { +export async function initializeExtension(cwds: ReadonlyArray, hideWelcomeMessage?: boolean) { disableFeatures(); @@ -323,6 +351,11 @@ export async function initializeExtension(cwds: ReadonlyArray { + console.log("LiveShare initialized"); + console.log(liveshareService); + console.log(wsLiveshare); + }); try { const courses = CurrentUser.getUserInfo().courses; if (courses && !showLiveshareBoardItem) { @@ -337,7 +370,7 @@ export async function initializeExtension(cwds: ReadonlyArray console.debug("Message dismissed")); } } catch (error) { APIClient.handleAxiosError(error); @@ -373,20 +401,26 @@ export async function initializeExtension(cwds: ReadonlyArray { - console.debug(value); - if (value === openDashboard) { - console.debug("Opening dashboard"); - return vscode.commands.executeCommand("vscode4teaching.showdashboard"); - } - }).then(() => console.debug("Message dismissed")); } } + vscode.commands.executeCommand("workbench.view.explorer").then(() => { + if (!hideWelcomeMessage) { + if (!currentUserIsTeacher) { + const message = `The exercise has been downloaded! You can start editing its files in the Explorer view. You can mark the exercise as finished using the 'Finish' button in the status bar below.`; + vscode.window.showInformationMessage(message).then(() => console.debug("Message dismissed")); + } else { + const message = `The exercise has been downloaded! You can see the template files and your students' files in the Explorer view. You can also open the Dashboard to monitor their progress (you can also open it from the status bar's 'Dashboard' button.`; + const openDashboard = "Open dashboard"; + vscode.window.showInformationMessage(message, openDashboard).then((value: string | undefined) => { + console.debug(value); + if (value === openDashboard) { + console.debug("Opening dashboard"); + return vscode.commands.executeCommand("vscode4teaching.showdashboard"); + } + }).then(() => console.debug("Message dismissed")); + } + } + }); // Set template location if exists if (currentUserIsTeacher && v4tjson.template) { // Template should be the same in the workspace @@ -414,21 +448,41 @@ function setStudentEvents(jszipFile: JSZip, cwd: vscode.WorkspaceFolder, zipUri: const pattern = new vscode.RelativePattern(cwd, "**/*"); const fsw = vscode.workspace.createFileSystemWatcher(pattern); changeEvent = fsw.onDidChange((e: vscode.Uri) => { - FileZipUtil.updateFile(jszipFile, e.fsPath, cwd.uri.fsPath, ignoredFiles, exerciseId).then(() => { - console.debug("File edited: " + e.fsPath); - }); - EUIUpdateService.updateExercise(e, exerciseId); + EUIUpdateService.addModifiedPath(e); + if (uploadTimeout) { + global.clearTimeout(uploadTimeout); + } + uploadTimeout = global.setTimeout(() => { + uploadTimeout = undefined; + FileZipUtil.updateFile(jszipFile, e.fsPath, cwd.uri.fsPath, ignoredFiles, exerciseId).then(() => { + console.debug("File edited: " + e.fsPath); + EUIUpdateService.updateExercise(exerciseId); + }); + }, 500); }); createEvent = fsw.onDidCreate((e: vscode.Uri) => { - FileZipUtil.updateFile(jszipFile, e.fsPath, cwd.uri.fsPath, ignoredFiles, exerciseId).then(() => { - console.debug("File added: " + e.fsPath); - }); - EUIUpdateService.updateExercise(e, exerciseId); + EUIUpdateService.addModifiedPath(e); + if (uploadTimeout) { + global.clearTimeout(uploadTimeout); + } + uploadTimeout = global.setTimeout(() => { + uploadTimeout = undefined; + FileZipUtil.updateFile(jszipFile, e.fsPath, cwd.uri.fsPath, ignoredFiles, exerciseId).then(() => { + console.debug("File added: " + e.fsPath); + EUIUpdateService.updateExercise(exerciseId); + }); + }, 500); }); deleteEvent = fsw.onDidDelete((e: vscode.Uri) => { - FileZipUtil.deleteFile(jszipFile, e.fsPath, cwd.uri.fsPath, ignoredFiles, exerciseId).then(() => { - console.debug("File deleted: " + e.fsPath); - }); + if (uploadTimeout) { + global.clearTimeout(uploadTimeout); + } + uploadTimeout = global.setTimeout(() => { + uploadTimeout = undefined; + FileZipUtil.deleteFile(jszipFile, e.fsPath, cwd.uri.fsPath, ignoredFiles, exerciseId).then(() => { + console.debug("File deleted: " + e.fsPath); + }); + }, 500); }); vscode.workspace.onWillSaveTextDocument((e: vscode.TextDocumentWillSaveEvent) => { @@ -489,13 +543,15 @@ async function getSingleStudentExerciseFiles(courseName: string, exercise: Exerc const username = CurrentUser.getUserInfo().username; const fileInfoPath = path.resolve(FileZipUtil.INTERNAL_FILES_DIR, username, ".fileInfo", exercise.name); await getFilesInfo(exercise, fileInfoPath, [username]); - const newWorkspace = vscode.workspace.updateWorkspaceFolders(0, + vscode.workspace.onDidChangeWorkspaceFolders(() => { + currentCwds = vscode.workspace.workspaceFolders; + if (currentCwds) { + initializeExtension(currentCwds, true); + } + }); + vscode.workspace.updateWorkspaceFolders(0, vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders.length : 0, { uri, name: exercise.name }); - currentCwds = vscode.workspace.workspaceFolders; - if (currentCwds && !newWorkspace) { - await initializeExtension(currentCwds); - } } } } @@ -518,8 +574,22 @@ async function getMultipleStudentExerciseFiles(courseName: string, exercise: Exe const newWorkspaceURIs = await getStudentExerciseFiles(courseName, exercise); if (newWorkspaceURIs && newWorkspaceURIs[1]) { const wsURI: string = newWorkspaceURIs[1]; - const directories = fs.readdirSync(wsURI, { withFileTypes: true }) + let directories = fs.readdirSync(wsURI, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()); + /* + Move "template" directory to beginning of directory array + As in the documentation for vscode.workspace.onDidChangeWorkspaceFolders: + + If the first workspace folder is added, removed or changed, the currently executing extensions + (including the one that called this method) will be terminated and restarted so that the (deprecated) + rootPath property is updated to point to the first workspace folder. + + The folder that never changes is the "template" one, so we move it to the beginning of the array to avoid + reloading all extensions if the same workspace is opened and there are new students added. + */ + const template = directories.filter((dirent) => dirent.name === "template")[0]; + directories = directories.filter((dirent) => dirent.name !== "template"); + directories.unshift(template); // Get file info for id references if (coursesProvider && CurrentUser.isLoggedIn()) { const usernames = directories.filter((dirent) => !dirent.name.includes("template")).map((dirent) => dirent.name); @@ -530,14 +600,16 @@ async function getMultipleStudentExerciseFiles(courseName: string, exercise: Exe uri: vscode.Uri.file(path.resolve(wsURI, dirent.name)), }; }); + vscode.workspace.onDidChangeWorkspaceFolders(() => { + currentCwds = vscode.workspace.workspaceFolders; + if (currentCwds) { + initializeExtension(currentCwds, true); + } + }); // open all student files and template - const newWorkspaces = vscode.workspace.updateWorkspaceFolders(0, + vscode.workspace.updateWorkspaceFolders(0, vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders.length : 0, ...subdirectoriesURIs); - currentCwds = vscode.workspace.workspaceFolders; - if (currentCwds && !newWorkspaces) { - await initializeExtension(currentCwds); - } } } } diff --git a/vscode4teaching-extension/src/model/serverModel/exercise/ExerciseUserInfo.ts b/vscode4teaching-extension/src/model/serverModel/exercise/ExerciseUserInfo.ts index 98cc5928..ac88deaa 100644 --- a/vscode4teaching-extension/src/model/serverModel/exercise/ExerciseUserInfo.ts +++ b/vscode4teaching-extension/src/model/serverModel/exercise/ExerciseUserInfo.ts @@ -6,5 +6,5 @@ export interface ExerciseUserInfo { user: User; status: number; updateDateTime: string; - lastModifiedFile?: string; + modifiedFiles?: string[]; } diff --git a/vscode4teaching-extension/src/services/EUIUpdateService.ts b/vscode4teaching-extension/src/services/EUIUpdateService.ts index 826ee366..6bac111e 100644 --- a/vscode4teaching-extension/src/services/EUIUpdateService.ts +++ b/vscode4teaching-extension/src/services/EUIUpdateService.ts @@ -1,32 +1,29 @@ +import escapeRegExp from "lodash.escaperegexp"; import * as vscode from "vscode"; import { APIClient } from "../client/APIClient"; -import { ExerciseUserInfo } from "../model/serverModel/exercise/ExerciseUserInfo"; import { FileZipUtil } from "../utils/FileZipUtil"; export class EUIUpdateService { - public static getExerciseInfoFromUri(uri: vscode.Uri) { + public static addModifiedPath(uri: vscode.Uri) { const matches = (this.URI_REGEX.exec(uri.path)); if (!matches) { return null; } matches.shift(); - return { - uri: uri.path, - path: matches[0], - username: matches[1], - courseName: matches[2], - exerciseName: matches[3], - fileName: matches[matches.length - 1], - }; + this.modifiedPaths.add(matches[0]); } - public static async updateExercise(uri: vscode.Uri, exerciseId: number) { - const info = this.getExerciseInfoFromUri(uri); + public static async updateExercise(exerciseId: number) { const response = await APIClient.getExerciseUserInfo(exerciseId); console.debug(response); const originalStatus = response.data.status; - const responseEui = await APIClient.updateExerciseUserInfo(exerciseId, originalStatus, info?.path || ""); + const responseEui = await APIClient.updateExerciseUserInfo(exerciseId, originalStatus, Array.from(this.modifiedPaths)); + this.modifiedPaths.clear(); console.debug(responseEui); } - private static readonly URI_REGEX: RegExp = new RegExp("/" + FileZipUtil.downloadDir + "(\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.+))$"); + // Regex to extract the path for the file. + // Group is the path with username, course and exercise included. + private static readonly URI_REGEX: RegExp = new RegExp(escapeRegExp(vscode.Uri.file(FileZipUtil.downloadDir).path) + "(\/[^\/]+\/[^\/]+\/[^\/]+\/.+)$", "i"); + private static modifiedPaths: Set = new Set(); + } diff --git a/vscode4teaching-extension/src/utils/FileZipUtil.ts b/vscode4teaching-extension/src/utils/FileZipUtil.ts index dbc2f152..7dd63c70 100644 --- a/vscode4teaching-extension/src/utils/FileZipUtil.ts +++ b/vscode4teaching-extension/src/utils/FileZipUtil.ts @@ -129,7 +129,8 @@ export class FileZipUtil { const v4tpath = v4tpathArray[i]; fs.writeFileSync(v4tpath, fileData); } catch (error) { - vscode.window.showErrorMessage(error); + console.error(error); + vscode.window.showErrorMessage(error as string); } } // The purpose of this file is to indicate this is an exercise directory to V4T to enable file uploads, etc @@ -142,7 +143,8 @@ export class FileZipUtil { fs.writeFileSync(path.resolve(zipInfo.dir, "v4texercise.v4t"), JSON.stringify(fileContent), { encoding: "utf8" }); return zipInfo.dir; } catch (error) { - vscode.window.showErrorMessage(error); + console.error(error); + vscode.window.showErrorMessage(error as string); } } diff --git a/vscode4teaching-extension/test/unitSuite/Client.test.ts b/vscode4teaching-extension/test/unitSuite/Client.test.ts index 91b95780..2845ea7b 100644 --- a/vscode4teaching-extension/test/unitSuite/Client.test.ts +++ b/vscode4teaching-extension/test/unitSuite/Client.test.ts @@ -33,10 +33,16 @@ function createSessionFile(newXsrfToken: string, newJwtToken: string) { fs.writeFileSync(APIClientSession.sessionPath, newJwtToken + "\n" + newXsrfToken); } -function expectSessionInvalidated() { - // Session is invalidated - expect(mockedCurrentUser.resetUserInfo).toHaveBeenCalledTimes(1); - expect(fs.existsSync(APIClientSession.sessionPath)).toBeFalsy(); +function expectSessionInvalidated(isInvalidated: boolean) { + if (isInvalidated) { + // Session is invalidated + expect(mockedCurrentUser.resetUserInfo).toHaveBeenCalledTimes(1); + expect(fs.existsSync(APIClientSession.sessionPath)).toBeFalsy(); + } else { + // Session is not invalidated + expect(mockedCurrentUser.resetUserInfo).toHaveBeenCalledTimes(0); + expect(fs.existsSync(APIClientSession.sessionPath)).toBeTruthy(); + } } describe("Client", () => { @@ -130,9 +136,9 @@ describe("Client", () => { expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledTimes(0); // Set status bar when fetching XSRF expect(mockedVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(2); - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "Fetching server info...", expect.anything()); + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "$(sync~spin) Fetching server info...", expect.anything()); // Set status bar when calling login - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(2, "Logging in to VS Code 4 Teaching...", expect.anything()); + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(2, "$(sync~spin) Logging in to VS Code 4 Teaching...", expect.anything()); // Make a request for XSRF Token expect(mockedAxios).toHaveBeenCalledTimes(2); expect(mockedAxios).toHaveBeenNthCalledWith(1, expectedAxiosConfigXSRFRequest); @@ -153,7 +159,7 @@ describe("Client", () => { it("should invalidate session", () => { APIClient.invalidateSession(); - expectSessionInvalidated(); + expectSessionInvalidated(true); }); it("should handle error 401", () => { @@ -170,7 +176,7 @@ describe("Client", () => { // Refresh tree view expect(mockedCoursesTreeProvider.triggerTreeReload).toHaveBeenCalledTimes(1); expect(global.console.error).toHaveBeenCalledTimes(1); - expectSessionInvalidated(); + expectSessionInvalidated(true); }); it("should handle error 403", () => { @@ -211,10 +217,11 @@ describe("Client", () => { expect(mockedVscode.window.showWarningMessage).toHaveBeenCalledWith("Something went wrong, please try again."); // Fetch XSRF Token expect(mockedVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(1); - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "Fetching server info...", expect.anything()); + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "$(sync~spin) Fetching server info...", expect.anything()); expect(mockedAxios).toHaveBeenCalledTimes(1); expect(mockedAxios).toHaveBeenNthCalledWith(1, expectedAxiosConfigXSRFRequest); expect(global.console.error).toHaveBeenCalledTimes(1); + expectSessionInvalidated(false); }); it("should handle rest of http errors", () => { @@ -233,7 +240,7 @@ describe("Client", () => { // Error message showed expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledTimes(1); expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledWith("Error 400. " + JSON.stringify(dataError)); - expectSessionInvalidated(); + expectSessionInvalidated(false); expect(global.console.error).toHaveBeenCalledTimes(1); }); @@ -249,7 +256,7 @@ describe("Client", () => { // Error message showed expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledTimes(1); expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledWith("Can't connect to the server. " + error.message); - expectSessionInvalidated(); + expectSessionInvalidated(true); expect(global.console.error).toHaveBeenCalledTimes(1); }); @@ -262,7 +269,7 @@ describe("Client", () => { // Error message showed expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledTimes(1); expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledWith(error.message); - expectSessionInvalidated(); + expectSessionInvalidated(false); expect(global.console.error).toHaveBeenCalledTimes(1); }); @@ -337,9 +344,9 @@ describe("Client", () => { expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledTimes(0); // Set status bar when fetching XSRF expect(mockedVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(2); - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "Fetching server info...", expect.anything()); + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "$(sync~spin) Fetching server info...", expect.anything()); // Set status bar when calling signup - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(2, "Signing up to VS Code 4 Teaching...", expect.anything()); + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(2, "$(sync~spin) Signing up to VS Code 4 Teaching...", expect.anything()); // Make a request for XSRF Token expect(mockedAxios).toHaveBeenCalledTimes(2); expect(mockedAxios).toHaveBeenNthCalledWith(1, expectedAxiosConfigXSRFRequest); @@ -403,7 +410,7 @@ describe("Client", () => { expect(mockedVscode.window.showWarningMessage).toHaveBeenCalledTimes(0); expect(mockedVscode.window.showErrorMessage).toHaveBeenCalledTimes(0); // Set status bar when calling signup - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "Signing teacher up to VS Code 4 Teaching...", expect.anything()); + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "$(sync~spin) Signing teacher up to VS Code 4 Teaching...", expect.anything()); // Make a request for signing up expect(mockedAxios).toHaveBeenCalledTimes(1); expect(mockedAxios).toHaveBeenNthCalledWith(1, expectedAxiosConfigSignupRequest); diff --git a/vscode4teaching-extension/test/unitSuite/ClientAPICalls.test.ts b/vscode4teaching-extension/test/unitSuite/ClientAPICalls.test.ts index a3683f23..02e25ee3 100644 --- a/vscode4teaching-extension/test/unitSuite/ClientAPICalls.test.ts +++ b/vscode4teaching-extension/test/unitSuite/ClientAPICalls.test.ts @@ -28,7 +28,7 @@ const baseUrl = "https://edukafora.codeurjc.es"; // This tests don't bother with the response of the calls, only the request parameters describe("client API calls", () => { - function expectCorrectRequest(options: AxiosRequestConfig, message: string, thenable: AxiosPromise) { + function expectCorrectRequest(options: AxiosRequestConfig, message: string, notification: boolean, thenable: AxiosPromise) { expect(mockedAxios).toHaveBeenCalledTimes(1); if (options.data instanceof FormData) { // if formdata content-type is generated randomly @@ -36,9 +36,18 @@ describe("client API calls", () => { options.headers = config.headers; options.data = config.data; } + if (notification) { + expect(mockedVscode.window.withProgress).toHaveBeenCalledTimes(1); + expect(mockedVscode.window.withProgress).toHaveBeenNthCalledWith(1, { + location: mockedVscode.ProgressLocation.Notification, + cancellable: false, + title: message, + }, expect.any(Function)); + } else { + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(1); + expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, "$(sync~spin) " + message, thenable); + } expect(mockedAxios).toHaveBeenNthCalledWith(1, options); - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(1); - expect(mockedVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith(1, message, thenable); } const xsrfToken = "test"; @@ -52,6 +61,7 @@ describe("client API calls", () => { afterEach(() => { mockedAxios.mockClear(); mockedVscode.window.setStatusBarMessage.mockClear(); + mockedVscode.window.withProgress.mockClear(); APIClientSession.xsrfToken = undefined; APIClientSession.jwtToken = undefined; }); @@ -78,7 +88,7 @@ describe("client API calls", () => { const thenable = APIClient.getServerUserInfo(); - expectCorrectRequest(expectedOptions, "Fetching user data...", thenable); + expectCorrectRequest(expectedOptions, "Fetching user data...", false, thenable); }); it("should request get exercises correctly", () => { @@ -100,7 +110,7 @@ describe("client API calls", () => { const thenable = APIClient.getExercises(courseId); - expectCorrectRequest(expectedOptions, "Fetching exercises...", thenable); + expectCorrectRequest(expectedOptions, "Fetching exercises...", false, thenable); }); it("should request get exercise files correctly", () => { @@ -122,7 +132,7 @@ describe("client API calls", () => { const thenable = APIClient.getExerciseFiles(exerciseId); - expectCorrectRequest(expectedOptions, "Downloading exercise files...", thenable); + expectCorrectRequest(expectedOptions, "Downloading exercise files...", true, thenable); }); it("should request add course correctly", () => { @@ -147,7 +157,7 @@ describe("client API calls", () => { const thenable = APIClient.addCourse(course); - expectCorrectRequest(expectedOptions, "Creating course...", thenable); + expectCorrectRequest(expectedOptions, "Creating course...", false, thenable); }); it("should request edit course correctly", () => { @@ -173,7 +183,7 @@ describe("client API calls", () => { const thenable = APIClient.editCourse(oldCourseId, course); - expectCorrectRequest(expectedOptions, "Editing course...", thenable); + expectCorrectRequest(expectedOptions, "Editing course...", false, thenable); }); it("should request delete course correctly", () => { @@ -196,7 +206,7 @@ describe("client API calls", () => { const thenable = APIClient.deleteCourse(oldCourseId); - expectCorrectRequest(expectedOptions, "Deleting course...", thenable); + expectCorrectRequest(expectedOptions, "Deleting course...", false, thenable); }); it("should request add exercise correctly", () => { @@ -222,7 +232,7 @@ describe("client API calls", () => { const thenable = APIClient.addExercise(courseId, exercise); - expectCorrectRequest(expectedOptions, "Adding exercise...", thenable); + expectCorrectRequest(expectedOptions, "Adding exercise...", false, thenable); }); it("should request edit exercise correctly", () => { @@ -248,7 +258,7 @@ describe("client API calls", () => { const thenable = APIClient.editExercise(exerciseId, exercise); - expectCorrectRequest(expectedOptions, "Sending exercise info...", thenable); + expectCorrectRequest(expectedOptions, "Sending exercise info...", false, thenable); }); it("should request upload exercise template correctly", () => { @@ -273,7 +283,7 @@ describe("client API calls", () => { const thenable = APIClient.uploadExerciseTemplate(exerciseId, data); - expectCorrectRequest(expectedOptions, "Uploading template...", thenable); + expectCorrectRequest(expectedOptions, "Uploading template...", true, thenable); }); it("should request delete exercise template correctly", () => { @@ -295,7 +305,7 @@ describe("client API calls", () => { const thenable = APIClient.deleteExercise(exerciseId); - expectCorrectRequest(expectedOptions, "Deleting exercise...", thenable); + expectCorrectRequest(expectedOptions, "Deleting exercise...", false, thenable); }); it("should request get all users correctly", () => { @@ -316,7 +326,7 @@ describe("client API calls", () => { const thenable = APIClient.getAllUsers(); - expectCorrectRequest(expectedOptions, "Fetching user data...", thenable); + expectCorrectRequest(expectedOptions, "Fetching user data...", false, thenable); }); it("should request get users in course correctly", () => { @@ -338,7 +348,7 @@ describe("client API calls", () => { const thenable = APIClient.getUsersInCourse(courseId); - expectCorrectRequest(expectedOptions, "Fetching user data...", thenable); + expectCorrectRequest(expectedOptions, "Fetching user data...", false, thenable); }); it("should request add users to course correctly", () => { @@ -363,7 +373,7 @@ describe("client API calls", () => { const thenable = APIClient.addUsersToCourse(courseId, data); - expectCorrectRequest(expectedOptions, "Adding users to course...", thenable); + expectCorrectRequest(expectedOptions, "Adding users to course...", false, thenable); }); it("should request remove users from course correctly", () => { @@ -388,7 +398,7 @@ describe("client API calls", () => { const thenable = APIClient.removeUsersFromCourse(courseId, data); - expectCorrectRequest(expectedOptions, "Removing users from course...", thenable); + expectCorrectRequest(expectedOptions, "Removing users from course...", false, thenable); }); it("should request get creator correctly", () => { @@ -410,7 +420,7 @@ describe("client API calls", () => { const thenable = APIClient.getCreator(courseId); - expectCorrectRequest(expectedOptions, "Getting course info...", thenable); + expectCorrectRequest(expectedOptions, "Getting course info...", false, thenable); }); it("should request upload files correctly", () => { @@ -435,7 +445,7 @@ describe("client API calls", () => { const thenable = APIClient.uploadFiles(exerciseId, data); - expectCorrectRequest(expectedOptions, "Uploading files...", thenable); + expectCorrectRequest(expectedOptions, "Uploading files...", true, thenable); }); it("should request get all student files correctly", () => { @@ -457,7 +467,7 @@ describe("client API calls", () => { const thenable = APIClient.getAllStudentFiles(exerciseId); - expectCorrectRequest(expectedOptions, "Downloading student files...", thenable); + expectCorrectRequest(expectedOptions, "Downloading student files...", true, thenable); }); it("should request get template correctly", () => { @@ -479,7 +489,7 @@ describe("client API calls", () => { const thenable = APIClient.getTemplate(exerciseId); - expectCorrectRequest(expectedOptions, "Downloading exercise template...", thenable); + expectCorrectRequest(expectedOptions, "Downloading exercise template...", true, thenable); }); it("should request get files info correctly", () => { @@ -502,7 +512,7 @@ describe("client API calls", () => { const thenable = APIClient.getFilesInfo(username, exerciseId); - expectCorrectRequest(expectedOptions, "Fetching file information...", thenable); + expectCorrectRequest(expectedOptions, "Fetching file information...", false, thenable); }); it("should request save comment correctly", () => { @@ -528,7 +538,7 @@ describe("client API calls", () => { const thenable = APIClient.saveComment(fileId, serverThread); - expectCorrectRequest(expectedOptions, "Saving comments...", thenable); + expectCorrectRequest(expectedOptions, "Saving comments...", false, thenable); }); it("should request get comment correctly", () => { @@ -550,7 +560,7 @@ describe("client API calls", () => { const thenable = APIClient.getComments(fileId); - expectCorrectRequest(expectedOptions, "Fetching comments...", thenable); + expectCorrectRequest(expectedOptions, "Fetching comments...", false, thenable); }); it("should request get all comment correctly", () => { @@ -573,7 +583,7 @@ describe("client API calls", () => { const thenable = APIClient.getAllComments(username, exerciseId); - expectCorrectRequest(expectedOptions, "Fetching comments...", thenable); + expectCorrectRequest(expectedOptions, "Fetching comments...", false, thenable); }); it("should request get sharing code for course correctly", () => { @@ -599,7 +609,7 @@ describe("client API calls", () => { const thenable = APIClient.getSharingCode(course); - expectCorrectRequest(expectedOptions, "Fetching sharing code...", thenable); + expectCorrectRequest(expectedOptions, "Fetching sharing code...", false, thenable); }); it("should request get sharing code for exercise correctly", () => { @@ -624,7 +634,7 @@ describe("client API calls", () => { const thenable = APIClient.getSharingCode(exercise); - expectCorrectRequest(expectedOptions, "Fetching sharing code...", thenable); + expectCorrectRequest(expectedOptions, "Fetching sharing code...", false, thenable); }); it("should request update comment thread line correctly", () => { @@ -650,7 +660,7 @@ describe("client API calls", () => { const thenable = APIClient.updateCommentThreadLine(fileId, lineInfo.line, lineInfo.lineText); - expectCorrectRequest(expectedOptions, "Saving comments...", thenable); + expectCorrectRequest(expectedOptions, "Saving comments...", false, thenable); }); it("should request get sharing code for exercise correctly", () => { @@ -672,7 +682,7 @@ describe("client API calls", () => { const thenable = APIClient.getCourseWithCode(code); - expectCorrectRequest(expectedOptions, "Fetching course data...", thenable); + expectCorrectRequest(expectedOptions, "Fetching course data...", false, thenable); }); it("should request get exercise user info for exercise correctly", () => { @@ -694,7 +704,7 @@ describe("client API calls", () => { const thenable = APIClient.getExerciseUserInfo(exerciseId); - expectCorrectRequest(expectedOptions, "Fetching exercise info for current user...", thenable); + expectCorrectRequest(expectedOptions, "Fetching exercise info for current user...", false, thenable); }); it("should request update exercise user info for exercise correctly", () => { @@ -719,7 +729,7 @@ describe("client API calls", () => { const thenable = APIClient.updateExerciseUserInfo(exerciseId, status); - expectCorrectRequest(expectedOptions, "Updating exercise user info...", thenable); + expectCorrectRequest(expectedOptions, "Updating exercise user info...", false, thenable); }); it("should request update exercise user info for exercise correctly", () => { @@ -741,6 +751,6 @@ describe("client API calls", () => { const thenable = APIClient.getAllStudentsExerciseUserInfo(exerciseId); - expectCorrectRequest(expectedOptions, "Fetching students' exercise user info...", thenable); + expectCorrectRequest(expectedOptions, "Fetching students' exercise user info...", true, thenable); }); }); diff --git a/vscode4teaching-extension/test/unitSuite/Commands.test.ts b/vscode4teaching-extension/test/unitSuite/Commands.test.ts index 343bc727..7032a70b 100644 --- a/vscode4teaching-extension/test/unitSuite/Commands.test.ts +++ b/vscode4teaching-extension/test/unitSuite/Commands.test.ts @@ -194,7 +194,7 @@ describe("Command implementations", () => { user, exercise, updateDateTime: new Date().toISOString(), - lastModifiedFile: "", + modifiedFiles: [], }; const response: AxiosResponse = { data: eui, @@ -433,6 +433,6 @@ describe("Command implementations", () => { expect(mockedPath.resolve).toHaveBeenNthCalledWith(2, "template", "file.txt"); expect(mockedFs.existsSync).toHaveBeenCalledTimes(1); expect(mockedVscode.commands.executeCommand).toHaveBeenCalledTimes(1); - expect(mockedVscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "vscode.diff", file, mockedVscode.Uri.file("template/file.txt")); + expect(mockedVscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "vscode.diff", mockedVscode.Uri.file("template/file.txt"), file); }); }); diff --git a/vscode4teaching-extension/test/unitSuite/DashboardWebview.test.ts b/vscode4teaching-extension/test/unitSuite/DashboardWebview.test.ts index fd19138e..03d9af98 100644 --- a/vscode4teaching-extension/test/unitSuite/DashboardWebview.test.ts +++ b/vscode4teaching-extension/test/unitSuite/DashboardWebview.test.ts @@ -60,7 +60,7 @@ describe("Dashboard webview", () => { user: student1, status: 0, updateDateTime: new Date(new Date(now.setDate(now.getDate() - 1)).toISOString()).toISOString(), - lastModifiedFile: "/index.html", + modifiedFiles: ["/index.html"], }); now = new Date(new Date().toLocaleString("en-US", { timeZone: "UTC" })); euis.push({ @@ -68,7 +68,7 @@ describe("Dashboard webview", () => { user: student2, status: 1, updateDateTime: new Date(new Date(now.setMinutes(now.getMinutes() - 13)).toISOString()).toISOString(), - lastModifiedFile: "/readme.md", + modifiedFiles: ["/readme.md"], }); now = new Date(new Date().toLocaleString("en-US", { timeZone: "UTC" })); euis.push({ @@ -76,7 +76,7 @@ describe("Dashboard webview", () => { user: student3, status: 2, updateDateTime: new Date(new Date(now.setSeconds(now.getSeconds() - 35)).toISOString()).toISOString(), - lastModifiedFile: undefined, + modifiedFiles: undefined, }); DashboardWebview.show(euis, course, exercise); if (DashboardWebview.currentPanel) { @@ -131,10 +131,8 @@ describe("Dashboard webview", () => { expect(tableData[2].attribs.class).toBe("not-started-cell"); expect(tableData[3].childNodes[0].name).toBe("button"); expect(tableData[3].childNodes[0].firstChild.data).toBe("Open"); - expect(tableData[3].childNodes[0].attribs["data-lastmod"]).toBe("/index.html"); expect(tableData[3].childNodes[1].name).toBe("button"); expect(tableData[3].childNodes[1].firstChild.data).toBe("Diff"); - expect(tableData[3].childNodes[1].attribs["data-lastmod-diff"]).toBe("/index.html"); // expect(tableData[4].firstChild.data === "1 d" || tableData[4].firstChild.data === "24 h").toBe(true); expect(tableData[5].firstChild.data).toBe("Student 2"); expect(tableData[6].firstChild.data).toBe("student2"); @@ -142,16 +140,13 @@ describe("Dashboard webview", () => { expect(tableData[7].attribs.class).toBe("finished-cell"); expect(tableData[8].childNodes[0].name).toBe("button"); expect(tableData[8].childNodes[0].firstChild.data).toBe("Open"); - expect(tableData[8].childNodes[0].attribs["data-lastmod"]).toBe("/readme.md"); expect(tableData[8].childNodes[1].name).toBe("button"); expect(tableData[8].childNodes[1].firstChild.data).toBe("Diff"); - expect(tableData[8].childNodes[1].attribs["data-lastmod-diff"]).toBe("/readme.md"); // expect(tableData[9].firstChild.data).toBe("13 min"); expect(tableData[10].firstChild.data).toBe("Student 3"); expect(tableData[11].firstChild.data).toBe("student3"); expect(tableData[12].firstChild.data).toBe("On progress"); expect(tableData[12].attribs.class).toBe("onprogress-cell"); - expect(tableData[13].firstChild.data).toBe("Not found"); // expect(tableData[14].firstChild.data).toBe("35 s"); } else { fail("Current panel wasn't created"); diff --git a/vscode4teaching-extension/test/unitSuite/EntryPoint.test.ts b/vscode4teaching-extension/test/unitSuite/EntryPoint.test.ts index f9a8bd97..943ea057 100644 --- a/vscode4teaching-extension/test/unitSuite/EntryPoint.test.ts +++ b/vscode4teaching-extension/test/unitSuite/EntryPoint.test.ts @@ -132,7 +132,7 @@ describe("Extension entry point", () => { user, status: 0, updateDateTime: new Date().toISOString(), - lastModifiedFile: "", + modifiedFiles: [], }; const euiResponse: AxiosResponse = { data: eui, @@ -193,6 +193,12 @@ describe("Extension entry point", () => { expect(fswFunctionMocks.onDidDelete).toHaveBeenCalledTimes(1); expect(mockedVscode.workspace.onWillSaveTextDocument).toHaveBeenCalledTimes(1); expect(mockedVscode.workspace.onDidSaveTextDocument).toHaveBeenCalledTimes(1); + expect(mockedVscode.commands.executeCommand).toHaveBeenCalledTimes(2); + expect(mockedVscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "setContext", "vscode4teaching.isTeacher", false); + expect(mockedVscode.commands.executeCommand).toHaveBeenNthCalledWith(2, "workbench.view.explorer"); + expect(mockedVscode.window.showInformationMessage).toHaveBeenCalledTimes(1); + const message = `The exercise has been downloaded! You can start editing its files in the Explorer view. You can mark the exercise as finished using the 'Finish' button in the status bar below.`; + expect(mockedVscode.window.showInformationMessage).toHaveBeenNthCalledWith(1, message); expect(extension.finishItem).toBeTruthy(); }); diff --git a/vscode4teaching-extension/test/unitSuite/__mocks__/vscode.js b/vscode4teaching-extension/test/unitSuite/__mocks__/vscode.js index 9cff34e8..8f8aba7b 100644 --- a/vscode4teaching-extension/test/unitSuite/__mocks__/vscode.js +++ b/vscode4teaching-extension/test/unitSuite/__mocks__/vscode.js @@ -56,6 +56,7 @@ const window = { showQuickPick: jest.fn((array, options) => { return Promise.resolve([...array]); }), + withProgress: jest.fn(), }; const WorkspaceConfiguration = { @@ -86,6 +87,7 @@ const workspace = { onWillSaveTextDocument: jest.fn(), updateWorkspaceFolders: jest.fn(), getWorkspaceFolder: jest.fn(), + onDidChangeWorkspaceFolders: jest.fn(), }; const Uri = jest.fn().mockImplementation((x) => { @@ -142,8 +144,8 @@ const Range = jest.fn().mockImplementation((startLine, startCharacter, endLine, }); const commands = { - registerCommand: jest.fn(), - executeCommand: jest.fn() + registerCommand: jest.fn(() => Promise.resolve({ data: {} })), + executeCommand: jest.fn(() => Promise.resolve({ data: {} })) }; const TreeItemCollapsibleState = { @@ -210,6 +212,10 @@ const MarkdownString = jest.fn().mockImplementation((text) => { const RelativePattern = jest.fn(); +const ProgressLocation = { + Notification: 0, +} + const vscode = { WorkspaceFolder, ExtensionContext, @@ -237,6 +243,7 @@ const vscode = { EndOfLine, MarkdownString, RelativePattern, + ProgressLocation }; module.exports = vscode; \ No newline at end of file diff --git a/vscode4teaching-server/pom.xml b/vscode4teaching-server/pom.xml index 1083ad29..d16dd617 100644 --- a/vscode4teaching-server/pom.xml +++ b/vscode4teaching-server/pom.xml @@ -12,7 +12,7 @@ com.vscode4teaching vscode4teaching-server - 2.0.1 + 2.0.2 VSCode 4 Teaching Server side of VSCode 4 Teaching extension. diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/DatabaseSuperuserInitializer.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/DatabaseSuperuserInitializer.java index 6c4f1a19..a74ac689 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/DatabaseSuperuserInitializer.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/DatabaseSuperuserInitializer.java @@ -57,7 +57,9 @@ private User saveUser(User user) { @Override public void run(String... args) throws Exception { - User superuser = new User(email, username, passwordEncoder.encode(password), name, lastname); - saveUser(superuser); + if (!userRepository.findByEmail(email).isPresent()) { + User superuser = new User(email, username, passwordEncoder.encode(password), name, lastname); + saveUser(superuser); + } } } \ No newline at end of file diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExceptionController.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExceptionController.java index 7706bdc7..3365ed8f 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExceptionController.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExceptionController.java @@ -10,6 +10,10 @@ import com.vscode4teaching.vscode4teachingserver.controllers.exceptioncontrol.ValidationErrorResponse; import com.vscode4teaching.vscode4teachingserver.controllers.exceptioncontrol.ValidationErrorResponse.ErrorDetail; import com.vscode4teaching.vscode4teachingserver.services.exceptions.CantRemoveCreatorException; +import com.vscode4teaching.vscode4teachingserver.services.exceptions.EmptyJSONObjectException; +import com.vscode4teaching.vscode4teachingserver.services.exceptions.EmptyURIException; +import com.vscode4teaching.vscode4teachingserver.services.exceptions.ExerciseFinishedException; +import com.vscode4teaching.vscode4teachingserver.services.exceptions.MissingPropertyException; import com.vscode4teaching.vscode4teachingserver.services.exceptions.NoTemplateException; import com.vscode4teaching.vscode4teachingserver.services.exceptions.NotCreatorException; import com.vscode4teaching.vscode4teachingserver.services.exceptions.NotFoundException; @@ -93,39 +97,28 @@ public ResponseEntity handleUsernameNotFoundException(UsernameNotFoundEx return new ResponseEntity<>("This user does not exist: " + e.getMessage(), HttpStatus.UNAUTHORIZED); } - @ExceptionHandler(NotInCourseException.class) + @ExceptionHandler(MalformedJwtException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) - public ResponseEntity handleNotInCourseException(NotInCourseException e) { + public ResponseEntity handleMalformedJwtException(MalformedJwtException e) { return new ResponseEntity<>(e.getMessage(), HttpStatus.UNAUTHORIZED); } + @ExceptionHandler(value = { NotInCourseException.class, CantRemoveCreatorException.class, NotCreatorException.class }) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ResponseEntity handleNotInCourseException(NotInCourseException e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.FORBIDDEN); + } + @ExceptionHandler(NoTemplateException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ResponseEntity handleNoTemplateException(NoTemplateException e) { return new ResponseEntity<>(e.getMessage(), HttpStatus.NOT_FOUND); } - @ExceptionHandler(MultipartException.class) + @ExceptionHandler(value = { ExerciseFinishedException.class, MultipartException.class, + EmptyJSONObjectException.class, EmptyURIException.class, MissingPropertyException.class}) @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity handleMultipartException(MultipartException e) { return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); } - - @ExceptionHandler(MalformedJwtException.class) - @ResponseStatus(HttpStatus.UNAUTHORIZED) - public ResponseEntity handleMalformedJwtException(MalformedJwtException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.UNAUTHORIZED); - } - - @ExceptionHandler(CantRemoveCreatorException.class) - @ResponseStatus(HttpStatus.UNAUTHORIZED) - public ResponseEntity handleCantRemoveCreatorException(CantRemoveCreatorException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.UNAUTHORIZED); - } - - @ExceptionHandler(NotCreatorException.class) - @ResponseStatus(HttpStatus.UNAUTHORIZED) - public ResponseEntity handleNotCreatorException(NotCreatorException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.UNAUTHORIZED); - } } \ No newline at end of file diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExerciseController.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExerciseController.java index 94649fd0..c2fd27f1 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExerciseController.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExerciseController.java @@ -106,7 +106,7 @@ public ResponseEntity getExerciseUserInfo(@PathVariable Long e public ResponseEntity updateExerciseUserInfo(@PathVariable Long exerciseId, @RequestBody ExerciseUserInfoDTO exerciseUserInfoDTO, HttpServletRequest request) throws NotFoundException { return ResponseEntity.ok(exerciseInfoService.updateExerciseUserInfo(exerciseId, - jwtTokenUtil.getUsernameFromToken(request), exerciseUserInfoDTO.getStatus(), exerciseUserInfoDTO.getLastModifiedFile())); + jwtTokenUtil.getUsernameFromToken(request), exerciseUserInfoDTO.getStatus(), exerciseUserInfoDTO.getModifiedFiles())); } @GetMapping("/exercises/{exerciseId}/info/teacher") diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/dtos/ExerciseUserInfoDTO.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/dtos/ExerciseUserInfoDTO.java index bd1d85d0..b88ad85a 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/dtos/ExerciseUserInfoDTO.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/dtos/ExerciseUserInfoDTO.java @@ -1,8 +1,10 @@ package com.vscode4teaching.vscode4teachingserver.controllers.dtos; +import java.util.List; + public class ExerciseUserInfoDTO { private int status; - private String lastModifiedFile; + private List modifiedFiles; public boolean isFinished() { return status == 1; @@ -20,11 +22,12 @@ public void setStatus(int status) { this.status = status; } - public String getLastModifiedFile() { - return lastModifiedFile; + public List getModifiedFiles() { + return modifiedFiles; } - public void setLastModifiedFile(String lastModifiedFile) { - this.lastModifiedFile = lastModifiedFile; + public void setModifiedFiles(List modifiedFiles) { + this.modifiedFiles = modifiedFiles; } + } \ No newline at end of file diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/ExerciseUserInfo.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/ExerciseUserInfo.java index 30315cc3..d895adae 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/ExerciseUserInfo.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/ExerciseUserInfo.java @@ -2,7 +2,11 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; @@ -44,8 +48,9 @@ public class ExerciseUserInfo { @JsonView(ExerciseUserInfoViews.GeneralView.class) private LocalDateTime updateDateTime; + @ElementCollection @JsonView(ExerciseUserInfoViews.GeneralView.class) - private String lastModifiedFile; + private Set modifiedFiles = new HashSet<>(); public ExerciseUserInfo() { @@ -83,13 +88,17 @@ public void setStatus(int status) { this.updateDateTime = LocalDateTime.now(ZoneOffset.UTC); } - public String getLastModifiedFile() { - return lastModifiedFile; + public Set getModifiedFiles() { + return modifiedFiles; } - public void setLastModifiedFile(String lastModifiedFile) { - if (lastModifiedFile != null) { - this.lastModifiedFile = lastModifiedFile; + public void setModifiedFiles(Set modifiedFiles) { + this.modifiedFiles = modifiedFiles; + } + + public void addModifiedFiles(Collection modifiedFiles) { + if (modifiedFiles != null) { + this.modifiedFiles.addAll(modifiedFiles); } } } \ No newline at end of file diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/repositories/UserRepository.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/repositories/UserRepository.java index 70475024..170d3764 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/repositories/UserRepository.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/model/repositories/UserRepository.java @@ -8,4 +8,5 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + Optional findByEmail(String email); } \ No newline at end of file diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/services/ExerciseInfoService.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/services/ExerciseInfoService.java index 3f7ae84e..0d86f35f 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/services/ExerciseInfoService.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/services/ExerciseInfoService.java @@ -19,7 +19,7 @@ public interface ExerciseInfoService { public ExerciseUserInfo getExerciseUserInfo(@Min(0) Long exerciseId, @NotEmpty String username) throws NotFoundException; - public ExerciseUserInfo updateExerciseUserInfo(@Min(0) Long exerciseId, @NotEmpty String username, int status, String lastModifiedFile) + public ExerciseUserInfo updateExerciseUserInfo(@Min(0) Long exerciseId, @NotEmpty String username, int status, List modifiedFiles) throws NotFoundException; public List getAllStudentExerciseUserInfo(@Min(0) Long exerciseId, String requestUsername) diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseFilesServiceImpl.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseFilesServiceImpl.java index 24c4d756..320517a1 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseFilesServiceImpl.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseFilesServiceImpl.java @@ -29,6 +29,9 @@ import com.vscode4teaching.vscode4teachingserver.services.exceptions.NotFoundException; import com.vscode4teaching.vscode4teachingserver.services.exceptions.NotInCourseException; +import org.hibernate.exception.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -40,6 +43,7 @@ public class ExerciseFilesServiceImpl implements ExerciseFilesService { private final ExerciseFileRepository fileRepository; private final ExerciseUserInfoRepository exerciseUserInfoRepository; private final UserRepository userRepository; + private final Logger logger = LoggerFactory.getLogger(ExerciseFilesServiceImpl.class); @Value("${v4t.filedirectory}") private String rootPath; @@ -104,6 +108,9 @@ private Map> saveFiles(Long exerciseId, MultipartFile file, .orElseThrow(() -> new NotInCourseException("User not in course: " + requestUsername)); ExceptionUtil.throwExceptionIfNotInCourse(course, requestUsername, isTemplate); String lastFolderPath = isTemplate ? "template" : requestUsername; + // For example, for root path "v4t_courses", a course "Course 1" with id 34, an exercise "Exercise 1" with id 77 + // and a user "john.doe" the final directory path would be + // v4t_courses/course_1_34/exercise_1_77/john.doe Path targetDirectory = Paths.get(rootPath + File.separator + course.getName().toLowerCase().replace(" ", "_") + "_" + course.getId() + File.separator + exercise.getName().toLowerCase().replace(" ", "_") + "_" + exercise.getId() + File.separator + lastFolderPath).toAbsolutePath().normalize(); @@ -114,35 +121,28 @@ private Map> saveFiles(Long exerciseId, MultipartFile file, ZipInputStream zis = new ZipInputStream(file.getInputStream()); ZipEntry zipEntry = zis.getNextEntry(); List files = new ArrayList<>(); - - Set pathSet = new HashSet<>(); while (zipEntry != null) { File destFile = newFile(targetDirectory.toFile(), zipEntry); - String parsed = ""; - if (File.separatorChar == '/') { - parsed = destFile.getAbsolutePath().replace("\\", "/"); - - } else if (File.separatorChar == '\\') { - parsed = destFile.getAbsolutePath().replace("/", "\\"); - } - if (!pathSet.contains(parsed)) { - pathSet.add(parsed); - if (zipEntry.isDirectory()) { - Files.createDirectories(destFile.toPath()); - } else { - if (!destFile.getParentFile().exists()) { - Files.createDirectories(destFile.getParentFile().toPath()); - } - files.add(destFile); - try (FileOutputStream fos = new FileOutputStream(destFile)) { - int len; - while ((len = zis.read(buffer)) > 0) { - fos.write(buffer, 0, len); - } - } - Optional previousFileOpt = fileRepository.findByPath(destFile.getCanonicalPath()); - if (!previousFileOpt.isPresent()) { - ExerciseFile exFile = new ExerciseFile(destFile.getCanonicalPath()); + if (zipEntry.isDirectory()) { + if (!destFile.isDirectory() && !destFile.mkdirs()) { + throw new IOException("Failed to create directory " + destFile); + } + } else { + // fix for Windows-created archives + File parent = destFile.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Failed to create directory " + parent); + } + files.add(destFile); + FileOutputStream fos = new FileOutputStream(destFile); + int len; + while ((len = zis.read(buffer)) > 0) { + fos.write(buffer, 0, len); + } + fos.close(); + ExerciseFile exFile = new ExerciseFile(destFile.getCanonicalPath()); + try { + if (!fileRepository.findByPath(destFile.getCanonicalPath()).isPresent()) { if (isTemplate) { ExerciseFile savedFile = fileRepository.save(exFile); exercise.addFileToTemplate(savedFile); @@ -152,9 +152,10 @@ private Map> saveFiles(Long exerciseId, MultipartFile file, exercise.addUserFile(savedFile); } } + } catch (ConstraintViolationException ex) { + logger.error(ex.getMessage()); } } - zipEntry = zis.getNextEntry(); } zis.closeEntry(); diff --git a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseInfoServiceImpl.java b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseInfoServiceImpl.java index b06907ab..4857ed4d 100644 --- a/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseInfoServiceImpl.java +++ b/vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/servicesimpl/ExerciseInfoServiceImpl.java @@ -41,11 +41,11 @@ public ExerciseUserInfo getExerciseUserInfo(@Min(0) Long exerciseId, @NotEmpty S } @Override - public ExerciseUserInfo updateExerciseUserInfo(@Min(0) Long exerciseId, @NotEmpty String username, int status, String lastModifiedFile) + public ExerciseUserInfo updateExerciseUserInfo(@Min(0) Long exerciseId, @NotEmpty String username, int status, List modifiedFiles) throws NotFoundException { ExerciseUserInfo eui = this.getAndCheckExerciseUserInfo(exerciseId, username); eui.setStatus(status); - eui.setLastModifiedFile(lastModifiedFile); + eui.addModifiedFiles(modifiedFiles); eui = exerciseUserInfoRepository.save(eui); this.websocketHandler.refreshExerciseDashboards(eui.getExercise().getCourse().getTeachers()); return eui; diff --git a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseControllerTests.java b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseControllerTests.java index 3640fb0f..56301927 100644 --- a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseControllerTests.java +++ b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseControllerTests.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.times; @@ -260,10 +262,12 @@ public void updateExerciseUserInfo_valid() throws Exception { user.setId(4l); ExerciseUserInfoDTO euiDTO = new ExerciseUserInfoDTO(); euiDTO.setStatus(1); - euiDTO.setLastModifiedFile("/sample"); + ArrayList euiModifiedFiles = new ArrayList<>(); + euiModifiedFiles.add("/sample"); + euiDTO.setModifiedFiles(euiModifiedFiles); ExerciseUserInfo updatedEui = new ExerciseUserInfo(ex, user); updatedEui.setStatus(1); - when(exerciseInfoService.updateExerciseUserInfo(1l, "johndoe", 1, "/sample")).thenReturn(updatedEui); + when(exerciseInfoService.updateExerciseUserInfo(anyLong(), anyString(), anyInt(), anyList())).thenReturn(updatedEui); MvcResult mvcResult = mockMvc .perform(put("/api/exercises/1/info").contentType("application/json").with(csrf()) diff --git a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseFilesControllerTests.java b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseFilesControllerTests.java index 51bb8669..5dc492e1 100644 --- a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseFilesControllerTests.java +++ b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/controllertests/ExerciseFilesControllerTests.java @@ -192,6 +192,7 @@ public void uploadFile() throws Exception { verify(filesService, times(1)).saveExerciseFiles(anyLong(), any(MultipartFile.class), anyString()); } + @Test public void uploadFile_noBody() throws Exception { mockMvc.perform(multipart("/api/exercises/1/files").with(csrf()).header("Authorization", diff --git a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/integrationtests/IntegrationTests.java b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/integrationtests/IntegrationTests.java index c3afc486..d7838fce 100644 --- a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/integrationtests/IntegrationTests.java +++ b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/integrationtests/IntegrationTests.java @@ -52,4 +52,38 @@ public void login_into_createCourse() throws Exception { Course actualResponse = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Course.class); assertThat(actualResponse.getName()).isEqualTo(course.getName()); } + + // Login, try to create course without body (fails with bad request) and token + // doesn't get invalidated on error + @Test + public void expectError() throws Exception { + JWTRequest jwtRequest = new JWTRequest(); + jwtRequest.setUsername("johndoe"); + jwtRequest.setPassword("teacherpassword"); + + MvcResult loginResult = mockMvc.perform(post("/api/login").contentType("application/json").with(csrf()) + .content(objectMapper.writeValueAsString(jwtRequest))).andExpect(status().isOk()).andReturn(); + JWTResponse jwtToken = objectMapper.readValue(loginResult.getResponse().getContentAsString(), + JWTResponse.class); + + MvcResult mvcErrorResult = mockMvc.perform(post("/api/courses").contentType("application/json").with(csrf()) + .header("Authorization", "Bearer " + jwtToken.getJwtToken())).andExpect(status().isBadRequest()) + .andReturn(); + + CourseDTO course = new CourseDTO(); + course.setName("Spring Boot Course"); + + MvcResult mvcCorrectResult = mockMvc + .perform(post("/api/courses").contentType("application/json").with(csrf()) + .content(objectMapper.writeValueAsString(course)) + .header("Authorization", "Bearer " + jwtToken.getJwtToken())) + .andExpect(status().isCreated()).andReturn(); + + String actualErrorResponse = mvcErrorResult.getResponse().getContentAsString(); + assertThat(actualErrorResponse).isEqualTo(""); + Course actualCorrectResponse = objectMapper.readValue(mvcCorrectResult.getResponse().getContentAsString(), + Course.class); + assertThat(actualCorrectResponse.getName()).isEqualTo(course.getName()); + } + } \ No newline at end of file diff --git a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseFilesServiceImplTests.java b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseFilesServiceImplTests.java index 297cb8b4..1e315caa 100644 --- a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseFilesServiceImplTests.java +++ b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseFilesServiceImplTests.java @@ -15,9 +15,11 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import com.vscode4teaching.vscode4teachingserver.model.Course; import com.vscode4teaching.vscode4teachingserver.model.Exercise; @@ -29,6 +31,7 @@ import com.vscode4teaching.vscode4teachingserver.model.repositories.ExerciseRepository; import com.vscode4teaching.vscode4teachingserver.model.repositories.ExerciseUserInfoRepository; import com.vscode4teaching.vscode4teachingserver.model.repositories.UserRepository; +import com.vscode4teaching.vscode4teachingserver.services.exceptions.ExerciseFinishedException; import com.vscode4teaching.vscode4teachingserver.services.exceptions.ExerciseNotFoundException; import com.vscode4teaching.vscode4teachingserver.services.exceptions.NoTemplateException; import com.vscode4teaching.vscode4teachingserver.services.exceptions.NotInCourseException; @@ -36,6 +39,7 @@ import org.apache.tomcat.util.http.fileupload.FileUtils; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -68,6 +72,13 @@ public class ExerciseFilesServiceImplTests { private static final Logger logger = LoggerFactory.getLogger(ExerciseFilesServiceImplTests.class); + private Set pathsSaved = new HashSet<>(); + + @BeforeEach + public void startup() { + this.pathsSaved = new HashSet<>(); + } + @AfterEach public void cleanup() { try { @@ -176,43 +187,21 @@ public void getExerciseFiles_noTemplate() throws Exception { verify(exerciseRepository, times(1)).findById(anyLong()); } - @Test - public void saveExerciseFiles() throws Exception { - User student = new User("johndoejr@gmail.com", "johndoe", "pass", "John", "Doe"); - student.setId(3l); - Role studentRole = new Role("ROLE_STUDENT"); - studentRole.setId(2l); - student.addRole(studentRole); - Course course = new Course("Spring Boot Course"); - course.setId(4l); - course.addUserInCourse(student); - Exercise exercise = new Exercise(); - exercise.setName("Exercise 1"); - exercise.setId(1l); - course.addExercise(exercise); - exercise.setCourse(course); - ExerciseUserInfo eui = new ExerciseUserInfo(exercise, student); - when(exerciseRepository.findById(anyLong())).thenReturn(Optional.of(exercise)); - when(userRepository.findByUsername(anyString())).thenReturn(Optional.of(student)); - when(fileRepository.save(any(ExerciseFile.class))).then(returnsFirstArg()); - when(exerciseRepository.save(any(Exercise.class))).then(returnsFirstArg()); - when(exerciseUserInfoRepository.findByExercise_IdAndUser_Username(anyLong(), anyString())) - .thenReturn(Optional.of(eui)); - when(fileRepository.findByPath(any(String.class))).thenReturn(Optional.empty()); + private List runSaveExerciseFiles() throws Exception { // Get files File file = Paths.get("src/test/java/com/vscode4teaching/vscode4teachingserver/files", "exs.zip").toFile(); MultipartFile mockFile = new MockMultipartFile("file", file.getName(), "application/zip", new FileInputStream(file)); Map> filesMap = filesService.saveExerciseFiles(1l, mockFile, "johndoe"); - List savedFiles = filesMap.values().stream().findFirst().get(); + return filesMap.values().stream().findFirst().get(); + } - verify(exerciseRepository, times(1)).findById(anyLong()); - verify(userRepository, times(1)).findByUsername(anyString()); - verify(fileRepository, times(3)).save(any(ExerciseFile.class)); - verify(exerciseRepository, times(1)).save(any(Exercise.class)); - verify(fileRepository, times(3)).findByPath(any(String.class)); - verify(exerciseUserInfoRepository, times(1)).findByExercise_IdAndUser_Username(anyLong(), anyString()); + private boolean checkPathHasBeenSaved(String path) { + return this.pathsSaved.add(path); + } + + private void fileAsserts() throws IOException { assertThat(Files.exists(Paths.get("null/"))).isTrue(); assertThat(Files.exists(Paths.get("null/spring_boot_course_4/exercise_1_1/"))).isTrue(); assertThat(Files.exists(Paths.get("null/spring_boot_course_4/exercise_1_1/johndoe"))).isTrue(); @@ -225,6 +214,11 @@ public void saveExerciseFiles() throws Exception { .contains("Exercise 2"); assertThat(Files.readAllLines(Paths.get("null/spring_boot_course_4/exercise_1_1/johndoe/ex3/ex3.html"))) .contains("Exercise 3"); + } + + private void exerciseUserInfoAsserts(ExerciseUserInfo eui) { + Exercise exercise = eui.getExercise(); + User student = eui.getUser(); assertThat(exercise.getUserFiles()).hasSize(3); assertThat(exercise.getUserFiles().get(0).getOwner()).isEqualTo(student); assertThat(exercise.getUserFiles().get(1).getOwner()).isEqualTo(student); @@ -235,12 +229,114 @@ public void saveExerciseFiles() throws Exception { Paths.get("null/spring_boot_course_4/exercise_1_1/johndoe/ex2.html").toAbsolutePath().toString()); assertThat(exercise.getUserFiles().get(2).getPath()).isEqualToIgnoringCase( Paths.get("null/spring_boot_course_4/exercise_1_1/johndoe/ex3/ex3.html").toAbsolutePath().toString()); + } + + private ExerciseUserInfo setupSaveExerciseFiles() { + User student = new User("johndoejr@gmail.com", "johndoe", "pass", "John", "Doe"); + student.setId(3l); + Role studentRole = new Role("ROLE_STUDENT"); + studentRole.setId(2l); + student.addRole(studentRole); + Course course = new Course("Spring Boot Course"); + course.setId(4l); + course.addUserInCourse(student); + Exercise exercise = new Exercise(); + exercise.setName("Exercise 1"); + exercise.setId(1l); + course.addExercise(exercise); + exercise.setCourse(course); + ExerciseUserInfo eui = new ExerciseUserInfo(exercise, student); + when(exerciseRepository.findById(anyLong())).thenReturn(Optional.of(exercise)); + when(userRepository.findByUsername(anyString())).thenReturn(Optional.of(student)); + when(fileRepository.findByPath(anyString())).thenAnswer(I -> { + String path = (String) I.getArguments()[0]; + if (this.checkPathHasBeenSaved(path)) { + return Optional.empty(); + } else { + return Optional.of(new ExerciseFile(path)); + } + }); + when(fileRepository.save(any(ExerciseFile.class))).then(returnsFirstArg()); + when(exerciseRepository.save(any(Exercise.class))).then(returnsFirstArg()); + when(exerciseUserInfoRepository.findByExercise_IdAndUser_Username(anyLong(), anyString())) + .thenReturn(Optional.of(eui)); + return eui; + } + + @Test + public void saveExerciseFiles() throws Exception { + ExerciseUserInfo eui = this.setupSaveExerciseFiles(); + Exercise exercise = eui.getExercise(); + + List savedFiles = this.runSaveExerciseFiles(); + + verify(exerciseRepository, times(1)).findById(anyLong()); + verify(userRepository, times(1)).findByUsername(anyString()); + verify(fileRepository, times(3)).findByPath(anyString()); + verify(fileRepository, times(3)).save(any(ExerciseFile.class)); + verify(exerciseRepository, times(1)).save(any(Exercise.class)); + verify(exerciseUserInfoRepository, times(1)).findByExercise_IdAndUser_Username(anyLong(), anyString()); + this.fileAsserts(); + this.exerciseUserInfoAsserts(eui); assertThat(savedFiles.size()).isEqualTo(3); assertThat(savedFiles.get(0).getAbsolutePath()).isEqualToIgnoringCase(exercise.getUserFiles().get(0).getPath()); assertThat(savedFiles.get(1).getAbsolutePath()).isEqualToIgnoringCase(exercise.getUserFiles().get(1).getPath()); assertThat(savedFiles.get(2).getAbsolutePath()).isEqualToIgnoringCase(exercise.getUserFiles().get(2).getPath()); } + @Test + public void saveExerciseFilesIgnoreDuplicates() throws Exception { + ExerciseUserInfo eui = this.setupSaveExerciseFiles(); + Exercise exercise = eui.getExercise(); + + // Run twice to send duplicates + this.runSaveExerciseFiles(); + List savedFiles = this.runSaveExerciseFiles(); + + verify(exerciseRepository, times(2)).findById(anyLong()); + verify(userRepository, times(2)).findByUsername(anyString()); + verify(fileRepository, times(6)).findByPath(anyString()); + verify(fileRepository, times(3)).save(any(ExerciseFile.class)); + verify(exerciseRepository, times(2)).save(any(Exercise.class)); + verify(exerciseUserInfoRepository, times(2)).findByExercise_IdAndUser_Username(anyLong(), anyString()); + this.fileAsserts(); + this.exerciseUserInfoAsserts(eui); + assertThat(savedFiles.get(0).getAbsolutePath()).isEqualToIgnoringCase(exercise.getUserFiles().get(0).getPath()); + assertThat(savedFiles.get(1).getAbsolutePath()).isEqualToIgnoringCase(exercise.getUserFiles().get(1).getPath()); + assertThat(savedFiles.get(2).getAbsolutePath()).isEqualToIgnoringCase(exercise.getUserFiles().get(2).getPath()); + } + + @Test + public void saveExerciseFilesFinishedError() throws Exception { + User student = new User("johndoejr@gmail.com", "johndoe", "pass", "John", "Doe"); + student.setId(3l); + Role studentRole = new Role("ROLE_STUDENT"); + studentRole.setId(2l); + student.addRole(studentRole); + Course course = new Course("Spring Boot Course"); + course.setId(4l); + course.addUserInCourse(student); + Exercise exercise = new Exercise(); + exercise.setName("Exercise 1"); + exercise.setId(1l); + course.addExercise(exercise); + exercise.setCourse(course); + ExerciseUserInfo eui = new ExerciseUserInfo(exercise, student); + eui.setStatus(1); + when(exerciseUserInfoRepository.findByExercise_IdAndUser_Username(anyLong(), anyString())) + .thenReturn(Optional.of(eui)); + // Get files + File file = Paths.get("src/test/java/com/vscode4teaching/vscode4teachingserver/files", "exs.zip").toFile(); + MultipartFile mockFile = new MockMultipartFile("file", file.getName(), "application/zip", + new FileInputStream(file)); + + ExerciseFinishedException e = assertThrows(ExerciseFinishedException.class, + () -> filesService.saveExerciseFiles(1l, mockFile, "johndoe")); + + assertThat(e.getMessage()).isEqualToIgnoringWhitespace("Exercise is marked as finished: 1"); + verify(exerciseUserInfoRepository, times(1)).findByExercise_IdAndUser_Username(anyLong(), anyString()); + } + @Test public void saveExerciseTemplate() throws Exception { User teacher = new User("johndoejr@gmail.com", "johndoe", "pass", "John", "Doe"); diff --git a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseInfoServiceImplTests.java b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseInfoServiceImplTests.java index 98623b53..d157e409 100644 --- a/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseInfoServiceImplTests.java +++ b/vscode4teaching-server/src/test/java/com/vscode4teaching/vscode4teachingserver/servicetests/ExerciseInfoServiceImplTests.java @@ -90,8 +90,11 @@ public void updateExerciseUserInfo_valid() throws NotFoundException { User user = new User("johndoe@john.com", username, "johndoeuser", "John", "Doe", studentRole); ExerciseUserInfo eui = new ExerciseUserInfo(exercise, user); eui.setStatus(0); - eui.setLastModifiedFile("/old/file.txt"); - String newFilePath = "modified/file.txt"; + Set euiOldModifiedFiles = new HashSet<>(); + euiOldModifiedFiles.add("/old/file.txt"); + eui.setModifiedFiles(euiOldModifiedFiles); + ArrayList euiNewModifiedFiles = new ArrayList<>(); + euiNewModifiedFiles.add("/modified/file.txt"); Optional euiOpt = Optional.of(eui); course.addUserInCourse(user); User creator = new User("creator@john.com", "creator", "creatorteacher", "creator", "Doe Sr", studentRole, new Role("ROLE_TEACHER")); @@ -103,12 +106,14 @@ public void updateExerciseUserInfo_valid() throws NotFoundException { teacherSet.add(creator); when(exerciseUserInfoRepository.findByExercise_IdAndUser_Username(exerciseId, username)).thenReturn(euiOpt); when(exerciseUserInfoRepository.save(any(ExerciseUserInfo.class))).then(returnsFirstArg()); - ExerciseUserInfo savedEui = exerciseInfoService.updateExerciseUserInfo(exerciseId, username, 1, newFilePath); + ExerciseUserInfo savedEui = exerciseInfoService.updateExerciseUserInfo(exerciseId, username, 1, euiNewModifiedFiles); assertThat(savedEui.getExercise()).isEqualTo(exercise); assertThat(savedEui.getUser()).isEqualTo(user); assertThat(savedEui.getStatus() == 1).isTrue(); - assertThat(savedEui.getLastModifiedFile()).isEqualTo(newFilePath); + assertThat(savedEui.getModifiedFiles()).size().isEqualTo(2); + assertThat(savedEui.getModifiedFiles()).contains("/modified/file.txt"); + assertThat(savedEui.getModifiedFiles()).contains("/old/file.txt"); verify(exerciseUserInfoRepository, times(1)).findByExercise_IdAndUser_Username(exerciseId, username); verify(exerciseUserInfoRepository, times(1)).save(any(ExerciseUserInfo.class)); verify(websocketHandler, times(1)).refreshExerciseDashboards(teacherSet); @@ -125,8 +130,9 @@ public void updateExerciseUserInfo_valid_no_file() throws NotFoundException { User user = new User("johndoe@john.com", username, "johndoeuser", "John", "Doe", studentRole); ExerciseUserInfo eui = new ExerciseUserInfo(exercise, user); eui.setStatus(0); - String oldFilePath = "/old/file.txt"; - eui.setLastModifiedFile(oldFilePath); + Set euiOldModifiedFiles = new HashSet<>(); + euiOldModifiedFiles.add("/old/file.txt"); + eui.setModifiedFiles(euiOldModifiedFiles); Optional euiOpt = Optional.of(eui); when(exerciseUserInfoRepository.findByExercise_IdAndUser_Username(exerciseId, username)).thenReturn(euiOpt); when(exerciseUserInfoRepository.save(any(ExerciseUserInfo.class))).then(returnsFirstArg()); @@ -135,7 +141,8 @@ public void updateExerciseUserInfo_valid_no_file() throws NotFoundException { assertThat(savedEui.getExercise()).isEqualTo(exercise); assertThat(savedEui.getUser()).isEqualTo(user); assertThat(savedEui.getStatus() == 0).isTrue(); - assertThat(savedEui.getLastModifiedFile()).isEqualTo(oldFilePath); + assertThat(savedEui.getModifiedFiles()).size().isEqualTo(1); + assertThat(savedEui.getModifiedFiles()).contains("/old/file.txt"); verify(exerciseUserInfoRepository, times(1)).findByExercise_IdAndUser_Username(exerciseId, username); verify(exerciseUserInfoRepository, times(1)).save(any(ExerciseUserInfo.class)); }