tmc_testmycode_client/
client.rs

1//! Contains the TmcClient struct for communicating with the TMC server.
2
3pub mod api_v8;
4
5use self::api_v8::{PasteData, ReviewData};
6use crate::{
7    TestMyCodeClientResult, error::TestMyCodeClientError, request::FeedbackAnswer, response::*,
8};
9use oauth2::{
10    AuthUrl, ClientId, ClientSecret, ResourceOwnerPassword, ResourceOwnerUsername, TokenUrl,
11    basic::BasicClient,
12};
13use reqwest::{
14    Url,
15    blocking::{Client, ClientBuilder},
16    redirect::Policy,
17};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use std::{
21    collections::HashMap,
22    io::{Cursor, Write},
23    path::{Path, PathBuf},
24    sync::Arc,
25    thread,
26    time::Duration,
27};
28use tmc_langs_plugins::{Compression, Language};
29use tmc_langs_util::progress_reporter;
30
31/// Authentication token.
32pub type Token =
33    oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>;
34
35/// Updated exercises.
36#[derive(Debug, Deserialize, Serialize, JsonSchema)]
37#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
38pub struct UpdateResult {
39    pub created: Vec<Exercise>,
40    pub updated: Vec<Exercise>,
41}
42
43/// A struct for interacting with the TestMyCode service, including authentication.
44#[derive(Clone)]
45pub struct TestMyCodeClient(Arc<TmcCore>);
46
47struct TmcCore {
48    client: Client,
49    oauth_client: Client,
50    root_url: Url,
51    token: Option<Token>,
52    client_name: String,
53    client_version: String,
54}
55
56// TODO: cache API results?
57impl TestMyCodeClient {
58    /// Convenience function for checking authentication.
59    pub fn require_authentication(&self) -> Result<(), TestMyCodeClientError> {
60        if self.0.token.is_some() {
61            Ok(())
62        } else {
63            Err(TestMyCodeClientError::NotAuthenticated)
64        }
65    }
66
67    /// Creates a new TestMyCodeClient with the given config directory and root URL.
68    ///
69    /// # Panics
70    /// If the root URL does not have a trailing slash and is not a valid URL with an appended trailing slash.
71    ///
72    /// # Examples
73    /// ```rust,no_run
74    /// use tmc_testmycode_client::TestMyCodeClient;
75    ///
76    /// let client = TestMyCodeClient::new("https://tmc.mooc.fi".parse().unwrap(), "some_client".to_string(), "some_version".to_string()).unwrap();
77    /// ```
78    pub fn new(
79        root_url: Url,
80        client_name: String,
81        client_version: String,
82    ) -> TestMyCodeClientResult<Self> {
83        // guarantee a trailing slash, otherwise join will drop the last component
84        let root_url = if root_url.as_str().ends_with('/') {
85            root_url
86        } else {
87            format!("{root_url}/").parse().expect("invalid root url")
88        };
89
90        let oauth_client = ClientBuilder::new()
91            .redirect(Policy::none())
92            .build()
93            .map_err(TestMyCodeClientError::HttpClientBuilder)?;
94
95        let client = TestMyCodeClient(Arc::new(TmcCore {
96            client: Client::new(),
97            oauth_client,
98            root_url,
99            token: None,
100            client_name,
101            client_version,
102        }));
103        Ok(client)
104    }
105
106    /// Sets the authentication token, which may for example have been read from a file.
107    ///
108    /// # Panics
109    /// If called when multiple clones of the client exist. Call this function before cloning.
110    pub fn set_token(&mut self, token: Token) {
111        Arc::get_mut(&mut self.0)
112            .expect("called when multiple clones exist")
113            .token = Some(token);
114    }
115
116    /// Attempts to log in with the given credentials, returns an error if an authentication token is already present.
117    /// Username can be the user's username or email.
118    ///
119    /// # Errors
120    /// This function will return an error if the client has already been authenticated,
121    /// if the client_name is malformed and leads to a malformed URL,
122    /// or if there is some error during the token exchange (see oauth2::Client::excange_password).
123    ///
124    /// # Panics
125    /// If called when multiple clones exist. Call this function before cloning.
126    ///
127    /// # Examples
128    /// ```rust,no_run
129    /// use tmc_testmycode_client::TestMyCodeClient;
130    ///
131    /// let mut client = TestMyCodeClient::new("https://tmc.mooc.fi".parse().unwrap(), "some_client".to_string(), "some_version".to_string()).unwrap();
132    /// client.authenticate("user".to_string(), "pass".to_string()).unwrap();
133    /// ```
134    pub fn authenticate(
135        &mut self,
136        email: String,
137        password: String,
138    ) -> TestMyCodeClientResult<Token> {
139        if self.0.token.is_some() {
140            return Err(Box::new(TestMyCodeClientError::AlreadyAuthenticated));
141        }
142
143        let auth_url = self.0.root_url.join("/oauth/token").map_err(|e| {
144            TestMyCodeClientError::UrlParse(self.0.root_url.to_string() + "/oauth/token", e)
145        })?;
146
147        let credentials = api_v8::get_credentials(self)?;
148
149        log::debug!("authenticating at {auth_url}");
150        let client = BasicClient::new(ClientId::new(credentials.application_id))
151            .set_client_secret(ClientSecret::new(credentials.secret))
152            .set_auth_uri(AuthUrl::from_url(auth_url.clone()))
153            .set_token_uri(TokenUrl::from_url(auth_url));
154
155        let token = client
156            .exchange_password(
157                &ResourceOwnerUsername::new(email),
158                &ResourceOwnerPassword::new(password),
159            )
160            .request(&self.0.oauth_client)
161            .map_err(TestMyCodeClientError::Token)?;
162        Arc::get_mut(&mut self.0)
163            .expect("called when multiple clones exist")
164            .token = Some(token.clone());
165        log::debug!("authenticated");
166        Ok(token)
167    }
168
169    /// Fetches the course's information. Does not require authentication.
170    ///
171    /// # Errors
172    /// If there's some problem reaching the API, or if the API returns an error.
173    pub fn get_exercises_details(
174        &self,
175        exercise_ids: &[u32],
176    ) -> TestMyCodeClientResult<Vec<ExercisesDetails>> {
177        let res = api_v8::core::get_exercise_details(self, exercise_ids)?;
178        Ok(res.into_iter().collect())
179    }
180
181    /// Sends the submission to the server. Requires authentication.
182    ///
183    /// # Errors
184    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
185    pub fn submit(
186        &self,
187        exercise_id: u32,
188        submission_path: &Path,
189        submission_size_limit_mb: u32,
190        locale: Option<Language>,
191    ) -> TestMyCodeClientResult<NewSubmission> {
192        self.require_authentication()?;
193
194        start_stage(2, "Compressing submission...", None);
195        let (compressed, _hash) = tmc_langs_plugins::compress_project(
196            submission_path,
197            Compression::Zip,
198            false,
199            false,
200            false,
201            submission_size_limit_mb,
202        )
203        .map_err(TestMyCodeClientError::from)?;
204        progress_stage("Compressed submission. Posting submission...", None);
205
206        let result = api_v8::core::submit_exercise(
207            self,
208            exercise_id,
209            Cursor::new(compressed),
210            None,
211            None,
212            locale,
213        )?;
214        finish_stage(
215            format!(
216                "Sent submission to server, running at {0}",
217                result.show_submission_url
218            ),
219            ClientUpdateData::PostedSubmission(result.clone()),
220        );
221        Ok(result)
222    }
223
224    /// Downloads an old submission. Requires authentication.
225    ///
226    /// # Errors
227    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
228    pub fn download_old_submission(
229        &self,
230        submission_id: u32,
231        target: &mut dyn Write,
232    ) -> TestMyCodeClientResult<()> {
233        self.require_authentication()?;
234        log::info!("downloading old submission {submission_id}");
235        api_v8::core::download_submission(self, submission_id, target)?;
236        Ok(())
237    }
238    /// Sends the given submission as a paste. Requires authentication.
239    ///
240    /// # Errors
241    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
242    ///
243    /// # Examples
244    /// ```rust,no_run
245    /// use tmc_testmycode_client::{TestMyCodeClient, Language};
246    /// use url::Url;
247    /// use std::path::Path;
248    ///
249    /// let client = TestMyCodeClient::new("https://tmc.mooc.fi".parse().unwrap(), "some_client".to_string(), "some_version".to_string()).unwrap();
250    /// // authenticate
251    /// let new_submission = client.paste(
252    ///     123,
253    ///     Path::new("./exercises/python/123"),
254    ///     Some("my python solution".to_string()),
255    ///     Some(Language::Eng),
256    ///     1,
257    /// ).unwrap();
258    /// ```
259    pub fn paste(
260        &self,
261        exercise_id: u32,
262        submission_path: &Path,
263        paste_message: Option<String>,
264        locale: Option<Language>,
265        submission_size_limit_mb: u32,
266    ) -> TestMyCodeClientResult<NewSubmission> {
267        self.require_authentication()?;
268
269        // compress
270        start_stage(2, "Compressing paste submission...", None);
271        let (compressed, _hash) = tmc_langs_plugins::compress_project(
272            submission_path,
273            Compression::Zip,
274            false,
275            false,
276            false,
277            submission_size_limit_mb,
278        )
279        .map_err(TestMyCodeClientError::from)?;
280        progress_stage(
281            "Compressed paste submission. Posting paste submission...",
282            None,
283        );
284
285        let paste = if let Some(message) = paste_message {
286            PasteData::WithMessage(message)
287        } else {
288            PasteData::WithoutMessage
289        };
290
291        let result = api_v8::core::submit_exercise(
292            self,
293            exercise_id,
294            Cursor::new(compressed),
295            Some(paste),
296            None,
297            locale,
298        )?;
299
300        finish_stage(
301            format!("Paste finished, running at {0}", result.paste_url),
302            ClientUpdateData::PostedSubmission(result.clone()),
303        );
304        Ok(result)
305    }
306
307    /// Fetches exercise submissions for the authenticated user. Requires authentication.
308    ///
309    /// # Errors
310    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
311    pub fn get_exercise_submissions_for_current_user(
312        &self,
313        exercise_id: u32,
314    ) -> TestMyCodeClientResult<Vec<Submission>> {
315        self.require_authentication()?;
316        let res = api_v8::submission::get_exercise_submissions_for_current_user(self, exercise_id)?;
317        Ok(res.into_iter().collect())
318    }
319
320    /// Request code review. Requires authentication.
321    ///
322    /// # Errors
323    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
324    pub fn download_exercise(
325        &self,
326        exercise_id: u32,
327        target: &mut dyn Write,
328    ) -> TestMyCodeClientResult<()> {
329        self.require_authentication()?;
330        api_v8::core::download_exercise(self, exercise_id, target)?;
331        Ok(())
332    }
333
334    /// Downloads the model solution from the given url. Requires authentication.
335    ///
336    /// # Errors
337    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
338    /// The method extracts the downloaded model solution archive, which may fail.
339    pub fn download_model_solution(
340        &self,
341        exercise_id: u32,
342        target: &Path,
343    ) -> TestMyCodeClientResult<()> {
344        self.require_authentication()?;
345
346        let mut buf = vec![];
347        api_v8::core::download_exercise_solution(self, exercise_id, &mut buf)?;
348        tmc_langs_plugins::extract_project(Cursor::new(buf), target, Compression::Zip, false)
349            .map_err(TestMyCodeClientError::from)?;
350        Ok(())
351    }
352
353    pub fn download_model_solution_archive(
354        &self,
355        exercise_id: u32,
356        target: &mut dyn Write,
357    ) -> TestMyCodeClientResult<()> {
358        self.require_authentication()?;
359        api_v8::core::download_exercise_solution(self, exercise_id, target)?;
360        Ok(())
361    }
362
363    /// Fetches the course's information. Requires authentication.
364    ///
365    /// # Errors
366    /// If not authenticated, or if there's some problem reaching the API, or if the API returns an error.
367    pub fn get_course_details(&self, course_id: u32) -> TestMyCodeClientResult<CourseDetails> {
368        self.require_authentication()?;
369        let res = api_v8::core::get_course(self, course_id)?;
370        Ok(res)
371    }
372
373    /// Fetches the given course's exercises. Requires authentication.
374    ///
375    /// # Errors
376    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
377    pub fn get_course_exercises(
378        &self,
379        course_id: u32,
380    ) -> TestMyCodeClientResult<Vec<CourseExercise>> {
381        self.require_authentication()?;
382        let res = api_v8::exercise::get_course_exercises_by_id(self, course_id)?;
383        Ok(res.into_iter().collect())
384    }
385
386    /// Fetches the given course's data. Requires authentication.
387    ///
388    /// # Errors
389    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
390    pub fn get_course(&self, course_id: u32) -> TestMyCodeClientResult<CourseData> {
391        self.require_authentication()?;
392        let res = api_v8::course::get_by_id(self, course_id)?;
393        Ok(res)
394    }
395
396    /// Fetches all courses under the given organization. Requires authentication.
397    ///
398    /// # Errors
399    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
400    pub fn list_courses(&self, organization_slug: &str) -> TestMyCodeClientResult<Vec<Course>> {
401        self.require_authentication()?;
402        let res = api_v8::core::get_organization_courses(self, organization_slug)?;
403        Ok(res.into_iter().collect())
404    }
405
406    /// Fetches the exercise's details. Requires authentication.
407    ///
408    /// # Errors
409    /// If not authenticated, or if there's some problem reaching the API, or if the API returns an error.
410    pub fn get_exercise_details(
411        &self,
412        exercise_id: u32,
413    ) -> TestMyCodeClientResult<ExerciseDetails> {
414        self.require_authentication()?;
415        let res = api_v8::core::get_exercise(self, exercise_id)?;
416        Ok(res)
417    }
418
419    /// Fetches the course's exercises from the server,
420    /// and finds new or updated exercises. Requires authentication.
421    /// If an exercise's id is not found in the checksum map, it is considered new.
422    /// If an id is found, it is compared to the current one. If they are different,
423    /// it is considered updated.
424    ///
425    /// # Errors
426    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
427    ///
428    /// # Examples
429    /// ```rust,no_run
430    /// use tmc_testmycode_client::TestMyCodeClient;
431    ///
432    /// let client = TestMyCodeClient::new("https://tmc.mooc.fi".parse().unwrap(), "some_client".to_string(), "some_version".to_string()).unwrap();
433    /// // authenticate
434    /// let mut checksums = std::collections::HashMap::new();
435    /// checksums.insert(1234, "exercisechecksum".to_string());
436    /// let update_result = client.get_exercise_updates(600, checksums).unwrap();
437    /// ```
438    pub fn get_exercise_updates(
439        &self,
440        course_id: u32,
441        checksums: HashMap<u32, String>,
442    ) -> TestMyCodeClientResult<UpdateResult> {
443        self.require_authentication()?;
444
445        let mut new_exercises = vec![];
446        let mut updated_exercises = vec![];
447
448        let course = self.get_course_details(course_id)?;
449        for exercise in course.exercises {
450            if let Some(old_checksum) = checksums.get(&exercise.id) {
451                if &exercise.checksum != old_checksum {
452                    // updated
453                    updated_exercises.push(exercise);
454                }
455            } else {
456                // new
457                new_exercises.push(exercise);
458            }
459        }
460        Ok(UpdateResult {
461            created: new_exercises,
462            updated: updated_exercises,
463        })
464    }
465
466    /// Fetches an organization. Does not require authentication.
467    ///
468    /// # Errors
469    /// Returns an error if there's some problem reaching the API, or if the API returns an error.
470    pub fn get_organization(
471        &self,
472        organization_slug: &str,
473    ) -> TestMyCodeClientResult<Organization> {
474        let res = api_v8::organization::get_organization(self, organization_slug)?;
475        Ok(res)
476    }
477
478    /// Fetches all organizations. Does not require authentication.
479    ///
480    /// # Errors
481    /// Returns an error if there's some problem reaching the API, or if the API returns an error.
482    pub fn get_organizations(&self) -> TestMyCodeClientResult<Vec<Organization>> {
483        let res = api_v8::organization::get_organizations(self)?;
484        Ok(res.into_iter().collect())
485    }
486
487    /// Fetches unread reviews. Requires authentication.
488    ///
489    /// # Errors
490    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
491    pub fn get_unread_reviews(&self, course_id: u32) -> TestMyCodeClientResult<Vec<Review>> {
492        self.require_authentication()?;
493        let res = api_v8::core::get_course_reviews(self, course_id)?;
494        Ok(res.into_iter().collect())
495    }
496
497    /// Mark the review as read on the server. Requires authentication.
498    ///
499    /// # Errors
500    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
501    pub fn mark_review_as_read(
502        &self,
503        course_id: u32,
504        review_id: u32,
505    ) -> TestMyCodeClientResult<()> {
506        self.require_authentication()?;
507        api_v8::core::update_course_review(self, course_id, review_id, None, Some(true))?;
508        Ok(())
509    }
510
511    /// Request code review. Requires authentication.
512    ///
513    /// # Errors
514    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
515    pub fn request_code_review(
516        &self,
517        exercise_id: u32,
518        submission_path: &Path,
519        message_for_reviewer: Option<String>,
520        locale: Option<Language>,
521        submission_size_limit_mb: u32,
522    ) -> TestMyCodeClientResult<NewSubmission> {
523        self.require_authentication()?;
524
525        let (compressed, _hash) = tmc_langs_plugins::compress_project(
526            submission_path,
527            Compression::Zip,
528            false,
529            false,
530            false,
531            submission_size_limit_mb,
532        )
533        .map_err(TestMyCodeClientError::from)?;
534        let review = if let Some(message) = message_for_reviewer {
535            ReviewData::WithMessage(message)
536        } else {
537            ReviewData::WithoutMessage
538        };
539        let res = api_v8::core::submit_exercise(
540            self,
541            exercise_id,
542            Cursor::new(compressed),
543            None,
544            Some(review),
545            locale,
546        )?;
547        Ok(res)
548    }
549
550    /// Sends feedback. Requires authentication.
551    ///
552    /// # Errors
553    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
554    pub fn send_feedback(
555        &self,
556        submission_id: u32,
557        feedback: Vec<FeedbackAnswer>,
558    ) -> TestMyCodeClientResult<SubmissionFeedbackResponse> {
559        self.require_authentication()?;
560        let res = api_v8::core::post_submission_feedback(
561            self,
562            submission_id,
563            feedback.into_iter().collect(),
564        )?;
565        Ok(res)
566    }
567
568    /// Posts feedback to the given URL. Requires authentication.
569    ///
570    /// # Errors
571    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
572    pub fn send_feedback_to_url(
573        &self,
574        feedback_url: Url,
575        feedback: Vec<FeedbackAnswer>,
576    ) -> TestMyCodeClientResult<SubmissionFeedbackResponse> {
577        self.require_authentication()?;
578        let form = api_v8::prepare_feedback_form(feedback.into_iter().collect());
579        let res = api_v8::post_form::<SubmissionFeedbackResponse>(self, feedback_url, &form)?;
580        Ok(res)
581    }
582
583    /// Waits for a submission to finish. Requires authentication.
584    ///
585    /// # Errors
586    /// If not authenticated, there's some problem reaching the API, or if the API returns an error.
587    pub fn wait_for_submission(
588        &self,
589        submission_id: u32,
590    ) -> TestMyCodeClientResult<SubmissionFinished> {
591        let res = self.wait_for_submission_inner(|| api_v8::get_submission(self, submission_id))?;
592        Ok(res)
593    }
594
595    /// Waits for a submission to finish at the given URL. May require authentication
596    ///
597    /// # Errors
598    /// If authentication is required but the client is not authenticated, there's some problem reaching the API, or if the API returns an error.
599    pub fn wait_for_submission_at(
600        &self,
601        submission_url: Url,
602    ) -> TestMyCodeClientResult<SubmissionFinished> {
603        let res =
604            self.wait_for_submission_inner(|| api_v8::get_json(self, submission_url.clone(), &[]))?;
605        Ok(res)
606    }
607
608    // abstracts waiting for submission over different functions for getting the submission status
609    fn wait_for_submission_inner(
610        &self,
611        f: impl Fn() -> Result<SubmissionProcessingStatus, TestMyCodeClientError>,
612    ) -> Result<SubmissionFinished, TestMyCodeClientError> {
613        start_stage(4, "Waiting for submission", None);
614
615        let mut previous_status = None;
616        loop {
617            match f()? {
618                SubmissionProcessingStatus::Finished(f) => {
619                    finish_stage("Submission finished processing!", None);
620                    return Ok(*f);
621                }
622                SubmissionProcessingStatus::Processing(p) => {
623                    if p.status == SubmissionStatus::Hidden {
624                        // hidden status, return constructed status
625                        finish_stage("Submission status hidden, stopping waiting.", None);
626                        let finished = SubmissionFinished {
627                            api_version: 8,
628                            all_tests_passed: Some(true),
629                            user_id: 0,
630                            login: "0".to_string(),
631                            course: "0".to_string(),
632                            exercise_name: "string".to_string(),
633                            status: SubmissionStatus::Hidden,
634                            points: vec![],
635                            validations: None,
636                            valgrind: None,
637                            submission_url: "".to_string(),
638                            solution_url: None,
639                            submitted_at: "string".to_string(),
640                            processing_time: None,
641                            reviewed: false,
642                            requests_review: false,
643                            paste_url: None,
644                            message_for_paste: None,
645                            missing_review_points: vec![],
646                            test_cases: Some(vec![TestCase {
647                                name: "Hidden Exam Test: hidden_test".to_string(),
648                                successful: true,
649                                message: Some("Exam exercise sent to server successfully, you can now continue.".to_string()),
650                                exception: None,
651                                detailed_message: None,
652                            }]),
653                            error: None,
654                            feedback_answer_url: None,
655                            feedback_questions: None,
656                        };
657                        return Ok(finished);
658                    }
659
660                    match (&mut previous_status, p.sandbox_status) {
661                        (Some(previous), status) if status == *previous => {} // no change, ignore
662                        (_, status) => {
663                            // new status, update progress
664                            match status {
665                                SandboxStatus::Created => {
666                                    progress_stage("Created on sandbox", None)
667                                }
668                                SandboxStatus::SendingToSandbox => {
669                                    progress_stage("Sending to sandbox", None);
670                                }
671                                SandboxStatus::ProcessingOnSandbox => {
672                                    progress_stage("Processing on sandbox", None);
673                                }
674                            }
675                            previous_status = Some(status);
676                        }
677                    }
678                    thread::sleep(Duration::from_secs(1));
679                }
680            }
681        }
682    }
683}
684
685// convenience functions to make sure the progress report type is correct for tmc-testmycode-client
686fn start_stage(steps: u32, message: impl Into<String>, data: impl Into<Option<ClientUpdateData>>) {
687    progress_reporter::start_stage(steps, message.into(), data.into())
688}
689
690fn progress_stage(message: impl Into<String>, data: impl Into<Option<ClientUpdateData>>) {
691    progress_reporter::progress_stage(message.into(), data.into())
692}
693
694fn finish_stage(message: impl Into<String>, data: impl Into<Option<ClientUpdateData>>) {
695    progress_reporter::finish_stage(message.into(), data.into())
696}
697
698/// The update data type for the progress reporter.
699#[derive(Debug, Serialize, Deserialize)]
700#[serde(rename_all = "kebab-case")]
701#[serde(tag = "client-update-data-kind")]
702#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
703pub enum ClientUpdateData {
704    ExerciseDownload { id: u32, path: PathBuf },
705    PostedSubmission(NewSubmission),
706}
707
708#[cfg(test)]
709#[allow(clippy::unwrap_used)]
710mod test {
711    // many of TmcClient's functions simply call already tested functions from api_v8 and don't need testing
712    use super::*;
713    use mockito::{Matcher, Server};
714    use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType};
715    use std::sync::atomic::AtomicBool;
716
717    // sets up mock-authenticated TmcClient and logging
718    fn init() {
719        use log::*;
720        use simple_logger::*;
721
722        let _ = SimpleLogger::new()
723            .with_level(LevelFilter::Debug)
724            // mockito does some logging
725            .with_module_level("mockito", LevelFilter::Warn)
726            // reqwest does a lot of logging
727            .with_module_level("reqwest", LevelFilter::Warn)
728            // hyper does a lot of logging
729            .with_module_level("hyper", LevelFilter::Warn)
730            .init();
731    }
732
733    fn make_client(server: &Server) -> TestMyCodeClient {
734        let mut client = TestMyCodeClient::new(
735            server.url().parse().unwrap(),
736            "some_client".to_string(),
737            "some_ver".to_string(),
738        )
739        .unwrap();
740        let token = Token::new(
741            AccessToken::new("".to_string()),
742            BasicTokenType::Bearer,
743            EmptyExtraTokenFields {},
744        );
745        client.set_token(token);
746        client
747    }
748
749    #[test]
750    fn gets_exercise_updates() {
751        init();
752        let mut server = Server::new();
753        let client = make_client(&server);
754        let _m = server.mock("GET", "/api/v8/core/courses/1234")
755            .match_query(Matcher::AllOf(vec![
756                Matcher::UrlEncoded("client".to_string(), "some_client".to_string()),
757                Matcher::UrlEncoded("client_version".to_string(), "some_ver".to_string()),
758            ]))
759            .with_body(serde_json::json!({
760                "course": {
761                    "id": 588,
762                    "name": "mooc-2020-ohjelmointi",
763                    "title": "Ohjelmoinnin MOOC 2020, Ohjelmoinnin perusteet",
764                    "description": "Aikataulutettu Ohjelmoinnin MOOC 2020. Kurssin ensimmäinen puolisko. Tästä kurssista voi hakea opinto-oikeutta Helsingin yliopiston tietojenkäsittelytieteen osastolle.",
765                    "details_url": "https://tmc.mooc.fi/api/v8/core/courses/588",
766                    "unlock_url": "https://tmc.mooc.fi/api/v8/core/courses/588/unlock",
767                    "reviews_url": "https://tmc.mooc.fi/api/v8/core/courses/588/reviews",
768                    "comet_url": "https://tmc.mooc.fi:8443/comet",
769                    "spyware_urls": [
770                    "http://snapshots01.mooc.fi/"
771                    ],
772                    "unlockables": [],
773                    "exercises": [
774                    {
775                        "id": 12,
776                        "name": "unchanged",
777                        "locked": false,
778                        "deadline_description": "2020-01-20 23:59:59 +0200",
779                        "deadline": "2020-01-20T23:59:59.999+02:00",
780                        "soft_deadline": null,
781                        "soft_deadline_description": null,
782                        "checksum": "ab",
783                        "return_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/submissions",
784                        "zip_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/download",
785                        "returnable": true,
786                        "requires_review": false,
787                        "attempted": false,
788                        "completed": false,
789                        "reviewed": false,
790                        "all_review_points_given": true,
791                        "memory_limit": null,
792                        "runtime_params": [],
793                        "valgrind_strategy": "fail",
794                        "code_review_requests_enabled": false,
795                        "run_tests_locally_action_enabled": true
796                    },
797                    {
798                        "id": 23,
799                        "name": "updated",
800                        "locked": false,
801                        "deadline_description": "2020-01-20 23:59:59 +0200",
802                        "deadline": "2020-01-20T23:59:59.999+02:00",
803                        "soft_deadline": null,
804                        "soft_deadline_description": null,
805                        "checksum": "zz",
806                        "return_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/submissions",
807                        "zip_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/download",
808                        "returnable": true,
809                        "requires_review": false,
810                        "attempted": false,
811                        "completed": false,
812                        "reviewed": false,
813                        "all_review_points_given": true,
814                        "memory_limit": null,
815                        "runtime_params": [],
816                        "valgrind_strategy": "fail",
817                        "code_review_requests_enabled": false,
818                        "run_tests_locally_action_enabled": true
819                    },
820                    {
821                        "id": 34,
822                        "name": "new",
823                        "locked": false,
824                        "deadline_description": "2020-01-20 23:59:59 +0200",
825                        "deadline": "2020-01-20T23:59:59.999+02:00",
826                        "soft_deadline": null,
827                        "soft_deadline_description": null,
828                        "checksum": "cd",
829                        "return_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/submissions",
830                        "zip_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/download",
831                        "returnable": true,
832                        "requires_review": false,
833                        "attempted": false,
834                        "completed": false,
835                        "reviewed": false,
836                        "all_review_points_given": true,
837                        "memory_limit": null,
838                        "runtime_params": [],
839                        "valgrind_strategy": "fail",
840                        "code_review_requests_enabled": false,
841                        "run_tests_locally_action_enabled": true
842                    },]
843                }
844            }).to_string())
845            .create();
846
847        let mut checksums = HashMap::new();
848        checksums.insert(12, "ab".to_string());
849        checksums.insert(23, "bc".to_string());
850        let update_result = client.get_exercise_updates(1234, checksums).unwrap();
851
852        assert_eq!(update_result.created.len(), 1);
853        assert_eq!(update_result.created[0].id, 34);
854
855        assert_eq!(update_result.updated.len(), 1);
856        assert_eq!(update_result.updated[0].checksum, "zz");
857    }
858
859    #[test]
860    fn waits_for_submission() {
861        init();
862        let mut server = Server::new();
863        let client = make_client(&server);
864        let m = server
865            .mock("GET", "/api/v8/core/submissions/0")
866            .match_query(Matcher::AllOf(vec![
867                Matcher::UrlEncoded("client".to_string(), "some_client".to_string()),
868                Matcher::UrlEncoded("client_version".to_string(), "some_ver".to_string()),
869            ]))
870            .with_chunked_body(|w| {
871                static CALLED: AtomicBool = AtomicBool::new(false);
872                if !CALLED.load(std::sync::atomic::Ordering::SeqCst) {
873                    CALLED.store(true, std::sync::atomic::Ordering::SeqCst);
874                    w.write_all(
875                        br#"
876                    {
877                        "status": "processing",
878                        "sandbox_status": "created"
879                    }
880                    "#,
881                    )
882                    .unwrap();
883                } else {
884                    w.write_all(
885                        br#"
886                    {
887                        "api_version": 0,
888                        "user_id": 1,
889                        "login": "",
890                        "course": "",
891                        "exercise_name": "",
892                        "status": "processing",
893                        "points": [],
894                        "submission_url": "",
895                        "submitted_at": "",
896                        "reviewed": false,
897                        "requests_review": false,
898                        "missing_review_points": []
899                    }
900                    "#,
901                    )
902                    .unwrap();
903                }
904                Ok(())
905            })
906            .expect(2)
907            .create();
908
909        let _res = client.wait_for_submission(0).unwrap();
910        m.assert();
911    }
912
913    #[test]
914    fn asd() {}
915}