Skip to main content

aoc_api/
session.rs

1//! The endpoints.
2//!
3//! Each one is a free function over the [`Transport`] the request goes out
4//! through - [`input_text`], [`samples`], [`stars`], [`submit`] and the rest.
5//! [`Session`] is the same set of calls with the transport already in hand: it
6//! holds the session cookie and the one HTTP client built from it, so a
7//! program that has a session does not pass a transport around. The methods
8//! delegate to the functions, so the two are the same code and neither can
9//! drift from the other.
10//!
11//! Which puzzle a call is about is an argument either way, so one session
12//! serves a whole event without rebuilding a client per call - and so a caller
13//! keeping the transport in a type of its own pays that cost once too.
14//!
15//! Nothing here is throttled or cached; see the crate documentation for why
16//! that is the caller's decision.
17
18use crate::{
19    error::Error,
20    http::{ClientOptions, Request, ReqwestTransport, Response, Transport},
21    parse::{self, Hint, ParseError, Submission},
22    puzzle::{BASE_URL, Part, Puzzle, Year},
23};
24use std::{collections::BTreeMap, fmt, time::Duration};
25
26/// How Advent of Code judged a submitted answer.
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum Verdict {
30    /// The answer was accepted.
31    Correct,
32
33    /// The answer was rejected.
34    Incorrect {
35        /// Which way the answer was wrong, when the site says.
36        hint: Option<Hint>,
37        /// How long the site asks you to wait before trying again, when it
38        /// says.
39        wait: Option<Duration>,
40    },
41
42    /// The part was already solved, so the site refused to judge the
43    /// submission at all.
44    ///
45    /// The answer was compared against the one the puzzle page shows as
46    /// accepted instead, which costs one further request.
47    AlreadyComplete {
48        /// Whether the submitted answer is the accepted one.
49        correct: bool,
50    },
51
52    /// The site was not asking for an answer to that part at all.
53    ///
54    /// It says the same thing in two situations and distinguishes neither:
55    /// the part is not open yet, because part one is still unsolved; or the
56    /// part was never a question, which is day 25's second star - that day has
57    /// one puzzle, and its second star is awarded for holding the other
58    /// forty-nine. Either way nothing was judged and nothing was wrong.
59    WrongLevel,
60}
61
62impl Verdict {
63    /// Whether the submitted answer is the right one.
64    #[must_use]
65    pub const fn is_correct(&self) -> bool {
66        matches!(
67            self,
68            Self::Correct | Self::AlreadyComplete { correct: true }
69        )
70    }
71}
72
73impl fmt::Display for Verdict {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            Self::Correct => f.write_str("that's the right answer"),
77            Self::Incorrect { hint, wait } => {
78                f.write_str("that's not the right answer")?;
79                if let Some(hint) = hint {
80                    write!(f, "; it is {hint}")?;
81                }
82                if let Some(wait) = wait {
83                    write!(f, " (wait {} before trying again)", parse::describe(*wait))?;
84                }
85                Ok(())
86            }
87            Self::AlreadyComplete { correct: true } => {
88                f.write_str("already solved, with this answer")
89            }
90            Self::AlreadyComplete { correct: false } => {
91                f.write_str("already solved, with a different answer")
92            }
93            Self::WrongLevel => f.write_str("there is nothing to answer for this part"),
94        }
95    }
96}
97
98/// An authenticated conversation with Advent of Code.
99///
100/// ```no_run
101/// use aoc_api::{Part, Puzzle, Session};
102///
103/// # async fn example() -> Result<(), aoc_api::Error> {
104/// let session = Session::new("53616c7465645f5f...", "github.com/my-username/my-repo by me@example.com")?;
105/// let puzzle = Puzzle::at(2024, 7)?;
106///
107/// let input = session.input_text(puzzle).await?;
108/// let verdict = session.submit(puzzle, Part::One, "3749").await?;
109///
110/// println!("{} bytes of input, and {verdict}", input.len());
111/// # Ok(())
112/// # }
113/// ```
114///
115/// Every method here is the free function of the same name with the transport
116/// filled in, so a caller that already holds a [`Transport`] can skip this
117/// type entirely and call [`input_text`], [`submit`] and the rest directly.
118#[derive(Debug, Clone)]
119pub struct Session<T = ReqwestTransport> {
120    transport: T,
121}
122
123impl Session {
124    /// Opens a session authenticated with the given session cookie.
125    ///
126    /// The cookie is the value of the `session` cookie on `adventofcode.com`
127    /// while logged in, with or without a leading `session=`. It is a
128    /// credential: treat it like a password.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`Error::Transport`] if the cookie or the identification cannot
133    /// be sent in a header, or the HTTP client cannot be built.
134    pub fn new(cookie: &str, identification: &str) -> Result<Self, Error> {
135        Self::configured(&ClientOptions::new(cookie, identification))
136    }
137
138    /// Opens a session with the client configured explicitly.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`Error::Transport`] if the options cannot produce a client.
143    pub fn configured(options: &ClientOptions) -> Result<Self, Error> {
144        Ok(Self {
145            transport: ReqwestTransport::new(options)?,
146        })
147    }
148}
149
150impl<T: Transport> Session<T> {
151    /// Opens a session on a transport of your own, usually
152    /// [`FakeTransport`](crate::http::fake::FakeTransport) in a test.
153    pub const fn with_transport(transport: T) -> Self {
154        Self { transport }
155    }
156
157    /// The transport every request goes out through.
158    pub const fn transport(&self) -> &T {
159        &self.transport
160    }
161
162    /// Downloads a puzzle's personal input, without its trailing newline.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`Error::Unauthorized`] if the cookie was not accepted,
167    /// [`Error::Locked`] if the puzzle has not unlocked yet, or
168    /// [`Error::Transport`] if the request failed.
169    pub async fn input_text(&self, puzzle: Puzzle) -> Result<String, Error> {
170        input_text(&self.transport, puzzle).await
171    }
172
173    /// Downloads a puzzle's personal input as lines.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`Error::Unauthorized`] if the cookie was not accepted,
178    /// [`Error::Locked`] if the puzzle has not unlocked yet, or
179    /// [`Error::Transport`] if the request failed.
180    pub async fn input_lines(&self, puzzle: Puzzle) -> Result<Vec<String>, Error> {
181        input_lines(&self.transport, puzzle).await
182    }
183
184    /// Every sample block on a puzzle's page, in the order they appear.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`Error::Unauthorized`] if the cookie was not accepted,
189    /// [`Error::Locked`] if the puzzle has not unlocked yet, or
190    /// [`Error::Transport`] if the request failed. An unsolved puzzle still
191    /// has samples, but a locked one has no page at all.
192    pub async fn samples(&self, puzzle: Puzzle) -> Result<Vec<String>, Error> {
193        samples(&self.transport, puzzle).await
194    }
195
196    /// The `nth` sample block on a puzzle's page, counting from one.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`Error::Parse`] if `nth` is zero or the page has fewer sample
201    /// blocks than that, [`Error::Unauthorized`] if the cookie was not
202    /// accepted, [`Error::Locked`] if the puzzle has not unlocked yet, or
203    /// [`Error::Transport`] if the request failed.
204    pub async fn sample_text(&self, puzzle: Puzzle, nth: u8) -> Result<String, Error> {
205        sample_text(&self.transport, puzzle, nth).await
206    }
207
208    /// The `nth` sample block on a puzzle's page, as lines.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`Error::Parse`] if `nth` is zero or the page has fewer sample
213    /// blocks than that, [`Error::Unauthorized`] if the cookie was not
214    /// accepted, [`Error::Locked`] if the puzzle has not unlocked yet, or
215    /// [`Error::Transport`] if the request failed.
216    pub async fn sample_lines(&self, puzzle: Puzzle, nth: u8) -> Result<Vec<String>, Error> {
217        sample_lines(&self.transport, puzzle, nth).await
218    }
219
220    /// How many stars this account has earned in each event.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`Error::Unauthorized`] if the cookie was not accepted,
225    /// [`Error::Parse`] if the page listed no events, or [`Error::Transport`]
226    /// if the request failed.
227    pub async fn stars(&self) -> Result<BTreeMap<Year, u8>, Error> {
228        stars(&self.transport).await
229    }
230
231    /// The answer a puzzle's page shows as accepted for `part`.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`Error::Parse`] if that part is not solved yet,
236    /// [`Error::Unauthorized`] if the cookie was not accepted,
237    /// [`Error::Locked`] if the puzzle has not unlocked yet, or
238    /// [`Error::Transport`] if the request failed.
239    pub async fn accepted_answer(&self, puzzle: Puzzle, part: Part) -> Result<String, Error> {
240        accepted_answer(&self.transport, puzzle, part).await
241    }
242
243    /// Submits an answer and reports how the site judged it.
244    ///
245    /// A rejected answer is a [`Verdict`], not an error; see [`submit`] for
246    /// what the site's replies mean and when one call makes two requests.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`Error::Cooldown`] if an answer was submitted too recently for
251    /// this one to be judged, [`Error::Unauthorized`] if the cookie was not
252    /// accepted, [`Error::Locked`] if the puzzle has not unlocked yet,
253    /// [`Error::Parse`] if the reply was not one this crate knows, or
254    /// [`Error::Transport`] if the request failed.
255    pub async fn submit(&self, puzzle: Puzzle, part: Part, answer: &str) -> Result<Verdict, Error> {
256        submit(&self.transport, puzzle, part, answer).await
257    }
258}
259
260/// Downloads a puzzle's personal input, without its trailing newline.
261///
262/// ```
263/// use aoc_api::{Puzzle, http::fake::FakeTransport, session};
264///
265/// let transport = FakeTransport::serving("1721\n979\n366\n");
266/// let puzzle = Puzzle::at(2020, 1)?;
267///
268/// let runtime = tokio::runtime::Builder::new_current_thread().build()?;
269/// let input = runtime.block_on(session::input_text(&transport, puzzle))?;
270///
271/// assert_eq!(input, "1721\n979\n366");
272/// # Ok::<(), Box<dyn std::error::Error>>(())
273/// ```
274///
275/// # Errors
276///
277/// Returns [`Error::Unauthorized`] if the cookie was not accepted,
278/// [`Error::Locked`] if the puzzle has not unlocked yet, or
279/// [`Error::Transport`] if the request failed.
280pub async fn input_text<T: Transport>(transport: &T, puzzle: Puzzle) -> Result<String, Error> {
281    let body = get(transport, puzzle.input_url(), Some(puzzle)).await?;
282
283    Ok(body.trim_end_matches('\n').to_owned())
284}
285
286/// Downloads a puzzle's personal input as lines.
287///
288/// # Errors
289///
290/// As [`input_text`].
291pub async fn input_lines<T: Transport>(
292    transport: &T,
293    puzzle: Puzzle,
294) -> Result<Vec<String>, Error> {
295    Ok(lines(&input_text(transport, puzzle).await?))
296}
297
298/// Every sample block on a puzzle's page, in the order they appear.
299///
300/// # Errors
301///
302/// As [`input_text`]. An unsolved puzzle still has samples, but a locked one
303/// has no page at all.
304pub async fn samples<T: Transport>(transport: &T, puzzle: Puzzle) -> Result<Vec<String>, Error> {
305    Ok(parse::samples(&page(transport, puzzle).await?))
306}
307
308/// The `nth` sample block on a puzzle's page, counting from one.
309///
310/// # Errors
311///
312/// As [`samples`], plus [`Error::Parse`] if the page has fewer sample blocks
313/// than that, or if `nth` is zero.
314pub async fn sample_text<T: Transport>(
315    transport: &T,
316    puzzle: Puzzle,
317    nth: u8,
318) -> Result<String, Error> {
319    // Sample blocks count from one, so no page can answer a zero and fetching
320    // one to find that out spends a request that could never have succeeded.
321    if nth == 0 {
322        return Err(ParseError::SampleZero.into());
323    }
324
325    Ok(parse::sample(&page(transport, puzzle).await?, nth)?)
326}
327
328/// The `nth` sample block on a puzzle's page, as lines.
329///
330/// # Errors
331///
332/// As [`sample_text`].
333pub async fn sample_lines<T: Transport>(
334    transport: &T,
335    puzzle: Puzzle,
336    nth: u8,
337) -> Result<Vec<String>, Error> {
338    Ok(lines(&sample_text(transport, puzzle, nth).await?))
339}
340
341/// How many stars the account behind `transport` has earned in each event.
342///
343/// # Errors
344///
345/// Returns [`Error::Unauthorized`] if the cookie was not accepted,
346/// [`Error::Parse`] if the page listed no events, or [`Error::Transport`] if
347/// the request failed.
348pub async fn stars<T: Transport>(transport: &T) -> Result<BTreeMap<Year, u8>, Error> {
349    let body = get(transport, format!("{BASE_URL}/events"), None).await?;
350
351    Ok(parse::stars(&body)?)
352}
353
354/// The answer a puzzle's page shows as accepted for `part`.
355///
356/// # Errors
357///
358/// As [`samples`], plus [`Error::Parse`] if that part is not solved yet.
359pub async fn accepted_answer<T: Transport>(
360    transport: &T,
361    puzzle: Puzzle,
362    part: Part,
363) -> Result<String, Error> {
364    Ok(parse::accepted_answer(
365        &page(transport, puzzle).await?,
366        part,
367    )?)
368}
369
370/// Submits an answer and reports how the site judged it.
371///
372/// A rejected answer is a [`Verdict`], not an error. If the part turns out to
373/// be solved already the site refuses to judge anything, so the answer is
374/// compared against the accepted one on the puzzle page, which costs a second
375/// request. If that page shows no accepted answer for the part, the site was
376/// never asking for one: that is [`Verdict::WrongLevel`], not a failure to
377/// read the page.
378///
379/// # Errors
380///
381/// Returns [`Error::Cooldown`] if an answer was submitted too recently for
382/// this one to be judged, [`Error::Unauthorized`] if the cookie was not
383/// accepted, [`Error::Locked`] if the puzzle has not unlocked yet,
384/// [`Error::Parse`] if the reply was not one this crate knows, or
385/// [`Error::Transport`] if the request failed.
386pub async fn submit<T: Transport>(
387    transport: &T,
388    puzzle: Puzzle,
389    part: Part,
390    answer: &str,
391) -> Result<Verdict, Error> {
392    let request = Request::post_form(
393        puzzle.answer_url(),
394        vec![
395            ("level".to_owned(), part.number().to_string()),
396            ("answer".to_owned(), answer.to_owned()),
397        ],
398    );
399
400    let body = send(transport, request, Some(puzzle)).await?;
401
402    match parse::submission(&body)? {
403        Submission::Correct => Ok(Verdict::Correct),
404        Submission::Incorrect { hint, wait } => Ok(Verdict::Incorrect { hint, wait }),
405        Submission::TooRecent { wait } => Err(Error::Cooldown { wait }),
406        Submission::LoggedOut => Err(Error::Unauthorized),
407        Submission::AlreadyComplete => match accepted_answer(transport, puzzle, part).await {
408            Ok(accepted) => Ok(Verdict::AlreadyComplete {
409                correct: accepted.trim() == answer.trim(),
410            }),
411            Err(Error::Parse(ParseError::AcceptedAnswer { .. })) => Ok(Verdict::WrongLevel),
412            Err(error) => Err(error),
413        },
414    }
415}
416
417/// A puzzle's page.
418async fn page(transport: &impl Transport, puzzle: Puzzle) -> Result<String, Error> {
419    get(transport, puzzle.url(), Some(puzzle)).await
420}
421
422/// Reads a URL.
423async fn get(
424    transport: &impl Transport,
425    url: String,
426    puzzle: Option<Puzzle>,
427) -> Result<String, Error> {
428    send(transport, Request::get(url), puzzle).await
429}
430
431/// Sends a request and returns the body, once the reply is known to be one
432/// worth reading.
433async fn send(
434    transport: &impl Transport,
435    request: Request,
436    puzzle: Option<Puzzle>,
437) -> Result<String, Error> {
438    let response = transport.execute(request).await?;
439    check(&response, puzzle)?;
440
441    Ok(response.body)
442}
443
444/// The owned lines of a body, which is what the `_lines` calls hand back.
445fn lines(body: &str) -> Vec<String> {
446    body.lines().map(ToOwned::to_owned).collect()
447}
448
449/// Turns a reply that is not worth reading into the reason it is not.
450///
451/// The site distinguishes very little by status code, so the body has the last
452/// word - and it has it first, because a rejected cookie is not always a
453/// rejected request. The input endpoint refuses with a `400`, while the puzzle
454/// and events pages answer `200` with a perfectly ordinary page that happens
455/// to offer a log-in link. Both mean the same thing, and saying so beats
456/// failing later on a page that turned out to have no puzzle in it.
457fn check(response: &Response, puzzle: Option<Puzzle>) -> Result<(), Error> {
458    if parse::is_logged_out(&response.body) {
459        return Err(Error::Unauthorized);
460    }
461
462    if response.is_success() {
463        return Ok(());
464    }
465
466    match (response.status, puzzle) {
467        (401 | 403, _) => Err(Error::Unauthorized),
468        (404, Some(puzzle)) => Err(Error::Locked { puzzle }),
469        (status, _) => Err(Error::Status { status }),
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::http::fake::FakeTransport;
477
478    const PUZZLE: &str = include_str!("../tests/fixtures/puzzle-day.html");
479    const EVENTS: &str = include_str!("../tests/fixtures/events.html");
480    const EVENTS_LOGGED_OUT: &str = include_str!("../tests/fixtures/events-logged-out.html");
481    const CORRECT: &str = include_str!("../tests/fixtures/submit-correct.html");
482    const WRONG: &str = include_str!("../tests/fixtures/submit-wrong.html");
483    const COOLDOWN: &str = include_str!("../tests/fixtures/submit-cooldown.html");
484    const COMPLETED: &str = include_str!("../tests/fixtures/submit-already-complete.html");
485    const LOGGED_OUT: &str = include_str!("../tests/fixtures/logged-out.html");
486
487    fn puzzle() -> Puzzle {
488        Puzzle::at(2020, 1).expect("2020 has a day 1")
489    }
490
491    fn session(transport: FakeTransport) -> Session<FakeTransport> {
492        Session::with_transport(transport)
493    }
494
495    #[tokio::test]
496    async fn an_input_is_fetched_from_the_input_endpoint_without_its_trailing_newline() {
497        let session = session(FakeTransport::serving("1721\n979\n366\n"));
498
499        let input = session
500            .input_text(puzzle())
501            .await
502            .expect("the fake replies");
503
504        assert_eq!(input, "1721\n979\n366");
505        assert_eq!(
506            session.transport().requested_urls(),
507            ["https://adventofcode.com/2020/day/1/input"]
508        );
509    }
510
511    #[tokio::test]
512    async fn an_input_can_be_read_as_lines() {
513        let session = session(FakeTransport::serving("1721\n979\n366\n"));
514
515        let lines = session
516            .input_lines(puzzle())
517            .await
518            .expect("the fake replies");
519
520        assert_eq!(lines, ["1721", "979", "366"]);
521    }
522
523    #[tokio::test]
524    async fn samples_come_from_the_puzzle_page() {
525        let session = session(FakeTransport::serving(PUZZLE));
526
527        let sample = session
528            .sample_lines(puzzle(), 1)
529            .await
530            .expect("the fake replies");
531
532        assert_eq!(sample, ["1721", "979", "366", "299", "675", "1456"]);
533        assert_eq!(
534            session.transport().requested_urls(),
535            ["https://adventofcode.com/2020/day/1"]
536        );
537    }
538
539    #[tokio::test]
540    async fn a_sample_zero_never_reaches_the_transport() {
541        let session = session(FakeTransport::new());
542
543        let error = session
544            .sample_text(puzzle(), 0)
545            .await
546            .expect_err("sample blocks count from one");
547
548        assert!(
549            matches!(error, Error::Parse(ParseError::SampleZero)),
550            "{error}"
551        );
552        assert!(session.transport().requests().is_empty());
553    }
554
555    #[tokio::test]
556    async fn stars_are_read_from_the_events_page() {
557        let session = session(FakeTransport::serving(EVENTS));
558
559        let stars = session.stars().await.expect("the fake replies");
560
561        assert_eq!(stars.values().copied().collect::<Vec<_>>(), [0, 9, 50, 19]);
562        assert_eq!(
563            session.transport().requested_urls(),
564            ["https://adventofcode.com/events"]
565        );
566    }
567
568    #[tokio::test]
569    async fn an_accepted_answer_is_posted_to_the_answer_endpoint() {
570        let session = session(FakeTransport::serving(CORRECT));
571
572        let verdict = session
573            .submit(puzzle(), Part::Two, "241861950")
574            .await
575            .expect("the fake replies");
576
577        assert_eq!(verdict, Verdict::Correct);
578        assert!(verdict.is_correct());
579
580        let request = session
581            .transport()
582            .requests()
583            .pop()
584            .expect("exactly one request");
585        assert_eq!(request.url, "https://adventofcode.com/2020/day/1/answer");
586        assert_eq!(
587            request.form,
588            [
589                ("level".to_owned(), "2".to_owned()),
590                ("answer".to_owned(), "241861950".to_owned()),
591            ]
592        );
593    }
594
595    #[tokio::test]
596    async fn a_rejected_answer_is_a_verdict_rather_than_an_error() {
597        let session = session(FakeTransport::serving(WRONG));
598
599        let verdict = session
600            .submit(puzzle(), Part::One, "0")
601            .await
602            .expect("the fake replies");
603
604        assert_eq!(
605            verdict,
606            Verdict::Incorrect {
607                hint: None,
608                wait: Some(Duration::from_secs(60)),
609            }
610        );
611        assert!(!verdict.is_correct());
612    }
613
614    #[tokio::test]
615    async fn a_submission_on_cooldown_reports_the_remaining_wait() {
616        let session = session(FakeTransport::serving(COOLDOWN));
617
618        let error = session
619            .submit(puzzle(), Part::One, "514579")
620            .await
621            .expect_err("nothing was judged");
622
623        assert!(matches!(
624            error,
625            Error::Cooldown { wait } if wait == Duration::from_secs(270)
626        ));
627    }
628
629    #[tokio::test]
630    async fn an_answer_to_a_solved_part_is_checked_against_the_puzzle_page() {
631        let transport = FakeTransport::new();
632        transport.push_body(COMPLETED).push_body(PUZZLE);
633        let session = session(transport);
634
635        let verdict = session
636            .submit(puzzle(), Part::One, "514579")
637            .await
638            .expect("the fake replies twice");
639
640        assert_eq!(verdict, Verdict::AlreadyComplete { correct: true });
641        assert_eq!(
642            session.transport().requested_urls(),
643            [
644                "https://adventofcode.com/2020/day/1/answer",
645                "https://adventofcode.com/2020/day/1",
646            ]
647        );
648    }
649
650    #[tokio::test]
651    async fn a_different_answer_to_a_solved_part_is_still_wrong() {
652        let transport = FakeTransport::new();
653        transport.push_body(COMPLETED).push_body(PUZZLE);
654
655        let verdict = session(transport)
656            .submit(puzzle(), Part::One, "1")
657            .await
658            .expect("the fake replies twice");
659
660        assert_eq!(verdict, Verdict::AlreadyComplete { correct: false });
661        assert!(!verdict.is_correct());
662    }
663
664    // Day 25 asks one question and gives its second star away, and part two of
665    // any day refuses answers while part one is open. The site says "you don't
666    // seem to be solving the right level" to both, and its page has no
667    // accepted answer to compare against either way - which is a verdict about
668    // the question, not a failure to read the reply.
669    #[tokio::test]
670    async fn an_answer_the_site_never_asked_for_is_a_verdict_rather_than_a_parse_error() {
671        let pages = [
672            // Day 25: part one answered, no second question.
673            "<p>Your puzzle answer was <code>514579</code>.</p>",
674            // Part two, with part one still unsolved.
675            "<p>To begin, please identify yourself.</p>",
676        ];
677
678        for page in pages {
679            let transport = FakeTransport::new();
680            transport.push_body(COMPLETED).push_body(page);
681
682            let verdict = session(transport)
683                .submit(puzzle(), Part::Two, "241861950")
684                .await
685                .expect("the reply was understood");
686
687            assert_eq!(verdict, Verdict::WrongLevel);
688            assert!(!verdict.is_correct());
689        }
690    }
691
692    #[tokio::test]
693    async fn a_reply_asking_for_a_login_is_an_expired_cookie_whatever_its_status() {
694        let transport = FakeTransport::new();
695        transport.push(Response::new(400, LOGGED_OUT));
696
697        let error = session(transport)
698            .input_text(puzzle())
699            .await
700            .expect_err("the cookie was not accepted");
701
702        assert!(matches!(error, Error::Unauthorized), "{error}");
703    }
704
705    #[tokio::test]
706    async fn a_page_offering_a_login_is_an_expired_cookie_even_at_status_200() {
707        let session = session(FakeTransport::serving(EVENTS_LOGGED_OUT));
708
709        let error = session
710            .stars()
711            .await
712            .expect_err("the page has no stars because nobody is logged in");
713
714        assert!(matches!(error, Error::Unauthorized), "{error}");
715    }
716
717    #[tokio::test]
718    async fn a_puzzle_that_has_not_unlocked_says_so() {
719        let transport = FakeTransport::new();
720        transport.push(Response::new(
721            404,
722            "Please don't repeatedly request this endpoint before it unlocks!",
723        ));
724
725        let error = session(transport)
726            .input_text(puzzle())
727            .await
728            .expect_err("the puzzle is locked");
729
730        assert!(matches!(error, Error::Locked { puzzle } if puzzle == self::puzzle()));
731    }
732
733    #[tokio::test]
734    async fn an_unexpected_status_is_reported_as_itself() {
735        let transport = FakeTransport::new();
736        transport.push(Response::new(500, "<h1>Internal Server Error</h1>"));
737
738        let error = session(transport)
739            .stars()
740            .await
741            .expect_err("the site is unwell");
742
743        assert!(matches!(error, Error::Status { status: 500 }), "{error}");
744    }
745
746    #[tokio::test]
747    async fn a_reply_the_parser_does_not_know_is_an_error_rather_than_a_guess() {
748        let session = session(FakeTransport::serving("<p>Ho ho ho.</p>"));
749
750        let error = session
751            .submit(puzzle(), Part::One, "514579")
752            .await
753            .expect_err("the reply is unrecognised");
754
755        assert!(
756            matches!(error, Error::Parse(ParseError::Submission { .. })),
757            "{error}"
758        );
759    }
760
761    #[tokio::test]
762    async fn a_transport_failure_is_reported_rather_than_retried() {
763        let session = session(FakeTransport::new());
764
765        let error = session
766            .input_text(puzzle())
767            .await
768            .expect_err("nothing was queued");
769
770        assert!(matches!(error, Error::Transport(_)), "{error}");
771        assert_eq!(session.transport().requests().len(), 1);
772    }
773
774    #[test]
775    fn a_verdict_reads_like_the_site_phrases_it() {
776        assert_eq!(Verdict::Correct.to_string(), "that's the right answer");
777        assert_eq!(
778            Verdict::Incorrect {
779                hint: Some(Hint::TooHigh),
780                wait: Some(Duration::from_secs(60)),
781            }
782            .to_string(),
783            "that's not the right answer; it is too high (wait 1m before trying again)"
784        );
785        assert_eq!(
786            Verdict::AlreadyComplete { correct: true }.to_string(),
787            "already solved, with this answer"
788        );
789        assert_eq!(
790            Verdict::WrongLevel.to_string(),
791            "there is nothing to answer for this part"
792        );
793    }
794}