mwbot/
page.rs

1/*
2Copyright (C) 2021 Kunal Mehta <legoktm@debian.org>
3
4This program is free software: you can redistribute it and/or modify
5it under the terms of the GNU General Public License as published by
6the Free Software Foundation, either version 3 of the License, or
7(at your option) any later version.
8
9This program is distributed in the hope that it will be useful,
10but WITHOUT ANY WARRANTY; without even the implied warranty of
11MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12GNU General Public License for more details.
13
14You should have received a copy of the GNU General Public License
15along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 */
17
18use crate::edit::{EditResponse, SaveOptions, Saveable};
19#[cfg(feature = "upload")]
20#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
21use crate::file::File;
22#[cfg(feature = "generators")]
23#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
24use crate::generators::{
25    categories::Categories, langlinks::LangLinks, templates::Templates,
26    Generator,
27};
28use crate::parsoid::ImmutableWikicode;
29use crate::{Bot, Error, Result, Title};
30use mwapi_responses::prelude::*;
31use mwtimestamp::Timestamp;
32use once_cell::sync::OnceCell;
33use serde_json::Value;
34#[cfg(feature = "generators")]
35use std::collections::HashMap;
36use std::fmt::Display;
37use std::future::Future;
38use std::sync::Arc;
39use tokio::sync::OnceCell as AsyncOnceCell;
40use tracing::info;
41
42/// A `Page` represents a wiki page on a specific wiki (`Bot`). You can get
43/// metadata about a page, its contents (in HTML or wikitext) and edit the
44/// page.
45///
46/// Pages are obtained by calling `bot.page("<title>")?`. Each page is `Sync`
47/// and designed to easily `Clone`d so it can be sent across multiple threads
48/// for concurrent processing.
49///
50/// Most metadata lookups are internally batched and cached so it might not
51/// reflect the live state on the wiki if someone has edited or modified the
52/// page in the meantime. To get fresh information create a new `Page`
53/// instance.
54///
55/// Saving a page will respect `{{nobots}}` (if not disabled), wait as needed
56/// for the configured rate limit and automatically implement edit conflict
57/// detection.
58#[derive(Debug, Clone)]
59pub struct Page {
60    pub(crate) bot: Bot,
61    pub(crate) title: Title,
62    pub(crate) title_text: OnceCell<String>,
63    pub(crate) info: Arc<AsyncOnceCell<InfoResponseItem>>,
64    pub(crate) baserevid: OnceCell<u64>,
65}
66
67#[doc(hidden)]
68#[non_exhaustive]
69#[query(prop = "info", inprop = "associatedpage|url")]
70pub struct InfoResponse {}
71
72impl Page {
73    /// Get the title of the page
74    pub fn title(&self) -> &str {
75        self.title_text.get_or_init(|| {
76            let codec = &self.bot.config.codec;
77            codec.to_pretty(&self.title)
78        })
79    }
80
81    /// Get a reference to the underlying [`mwtitle::Title`](https://docs.rs/mwtitle/latest/mwtitle/struct.Title.html)
82    pub fn as_title(&self) -> &Title {
83        &self.title
84    }
85
86    /// Get the namespace ID of the page
87    pub fn namespace(&self) -> i32 {
88        self.title.namespace()
89    }
90
91    /// Whether this page refers to a file
92    pub fn is_file(&self) -> bool {
93        self.title.is_file()
94    }
95
96    /// If it is a file, get a `File` instance
97    #[cfg(feature = "upload")]
98    #[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
99    pub fn as_file(&self) -> Option<File> {
100        if self.is_file() {
101            Some(File::new(self))
102        } else {
103            None
104        }
105    }
106
107    /// Whether this page refers to a category
108    pub fn is_category(&self) -> bool {
109        self.title.is_category()
110    }
111
112    /// Load basic page information
113    async fn info(&self) -> Result<&InfoResponseItem> {
114        self.info
115            .get_or_try_init(|| async {
116                let mut resp: InfoResponse = mwapi_responses::query_api(
117                    &self.bot.api,
118                    [("titles", self.title())],
119                )
120                .await?;
121                let info = resp
122                    .query
123                    .pages
124                    .pop()
125                    .expect("API response returned 0 pages");
126                if let Some(revid) = info.lastrevid {
127                    let _ = self.baserevid.set(revid);
128                }
129                Ok(info)
130            })
131            .await
132    }
133
134    /// Whether the page exists or not
135    pub async fn exists(&self) -> Result<bool> {
136        Ok(!self.info().await?.missing)
137    }
138
139    /// Get the page's internal database ID, if it exists
140    pub async fn id(&self) -> Result<Option<u32>> {
141        Ok(self.info().await?.pageid)
142    }
143
144    /// Get the canonical URL for this page
145    pub async fn url(&self) -> Result<&str> {
146        Ok(&self.info().await?.canonicalurl)
147    }
148
149    /// Whether the page is a redirect or not
150    pub async fn is_redirect(&self) -> Result<bool> {
151        Ok(self.info().await?.redirect)
152    }
153
154    /// The associated page for this page (subject page for a talk page or
155    /// talk page for a subject page)
156    pub async fn associated_page(&self) -> Result<Page> {
157        self.bot.page(&self.info().await?.associatedpage)
158    }
159
160    /// Get the "touched" timestamp, if the page exists.
161    ///
162    /// From the [MediaWiki documentation](https://www.mediawiki.org/wiki/Manual:Page_table#page_touched):
163    /// > This timestamp is updated whenever the page changes in a way requiring it to be re-rendered,
164    /// > invalidating caches. Aside from editing, this includes permission changes, creation or deletion
165    /// > of linked pages, and alteration of contained templates.
166    pub async fn touched(&self) -> Result<Option<Timestamp>> {
167        Ok(self.info().await?.touched)
168    }
169
170    /// Get the ID of the latest revision, if the page exists.
171    pub async fn latest_revision_id(&self) -> Result<Option<u64>> {
172        Ok(self.info().await?.lastrevid)
173    }
174
175    /// If this page is a redirect, get the `Page` it targets
176    pub async fn redirect_target(&self) -> Result<Option<Page>> {
177        // Optimize if we already know it's not a redirect
178        if self.info.initialized() && !self.is_redirect().await? {
179            return Ok(None);
180        }
181        // Do an API request to resolve the redirect
182        let mut resp: InfoResponse = mwapi_responses::query_api(
183            &self.bot.api,
184            [("titles", self.title()), ("redirects", "1")],
185        )
186        .await?;
187        match resp.title_map().get(self.title()) {
188            Some(redirect) => {
189                let page = self.bot.page(redirect)?;
190                page.info
191                    .set(
192                        resp.query
193                            .pages
194                            .pop()
195                            .expect("API response returned 0 pages"),
196                    )
197                    // unwrap: Safe because we just created the page
198                    .unwrap();
199                Ok(Some(page))
200            }
201            None => Ok(None),
202        }
203    }
204
205    /// Get the inter-language links of the latest revision, if the page exists.
206    #[cfg(feature = "generators")]
207    #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
208    pub async fn language_links(
209        &self,
210    ) -> Result<Option<HashMap<String, String>>> {
211        if self.info.initialized() && !self.exists().await? {
212            return Ok(None);
213        }
214        let mut gen =
215            LangLinks::new(vec![self.title().to_string()]).generate(&self.bot);
216        let page = gen.recv().await.unwrap()?;
217        debug_assert_eq!(page.title, self.title());
218        debug_assert!(gen.recv().await.is_none());
219        if page.missing || page.invalid {
220            return Ok(None);
221        }
222        let links = page
223            .langlinks
224            .into_iter()
225            .map(|item| (item.lang, item.title))
226            .collect();
227        Ok(Some(links))
228    }
229
230    /// Get the categories of the latest revision, if the page exists.
231    #[cfg(feature = "generators")]
232    #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
233    pub async fn categories(&self) -> Result<Option<Vec<String>>> {
234        if self.info.initialized() && !self.exists().await? {
235            return Ok(None);
236        }
237        let mut gen =
238            Categories::new(vec![self.title().to_string()]).generate(&self.bot);
239        let mut found = Vec::new();
240
241        while let Some(page) = gen.recv().await {
242            found.push(page?.title().to_string());
243        }
244        Ok(Some(found))
245    }
246
247    /// Get the templates used in the latest revision, if the page exists.
248    #[cfg(feature = "generators")]
249    #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
250    pub async fn templates(
251        &self,
252        only: Option<Vec<String>>,
253    ) -> Result<Option<Vec<String>>> {
254        if self.info.initialized() && !self.exists().await? {
255            return Ok(None);
256        }
257        let mut gen = Templates::new(vec![self.title().to_string()])
258            .with_templates(only)
259            .generate(&self.bot);
260        let mut found = Vec::new();
261
262        while let Some(page) = gen.recv().await {
263            found.push(page?.title().to_string());
264        }
265        Ok(Some(found))
266    }
267
268    /// Get Parsoid HTML for self.baserevid if it's set, or the latest revision otherwise
269    pub async fn html(&self) -> Result<ImmutableWikicode> {
270        match self.baserevid.get() {
271            None => {
272                let resp = self.bot.parsoid.get(self.title()).await?;
273                // Keep track of revision id for saving in the future
274                if let Some(revid) = &resp.revision_id() {
275                    let _ = self.baserevid.set(*revid);
276                }
277                Ok(resp)
278            }
279            Some(revid) => {
280                Ok(self.bot.parsoid.get_revision(self.title(), *revid).await?)
281            }
282        }
283    }
284
285    /// Get Parsoid HTML for the specified revision
286    pub async fn revision_html(&self, revid: u64) -> Result<ImmutableWikicode> {
287        Ok(self.bot.parsoid.get_revision(self.title(), revid).await?)
288    }
289
290    /// Get wikitext for self.baserevid if it's set, or the latest revision otherwise
291    pub async fn wikitext(&self) -> Result<String> {
292        let mut params: Vec<(&'static str, String)> = vec![
293            ("action", "query".to_string()),
294            ("titles", self.title().to_string()),
295            ("prop", "revisions".to_string()),
296            ("rvprop", "content|ids".to_string()),
297            ("rvslots", "main".to_string()),
298        ];
299        if let Some(revid) = self.baserevid.get() {
300            params.push(("rvstartid", revid.to_string()));
301            params.push(("rvendid", revid.to_string()));
302        }
303        let resp = self.bot.api.get_value(&params).await?;
304        let page = resp["query"]["pages"][0].as_object().unwrap();
305        if page.contains_key("missing") {
306            Err(Error::PageDoesNotExist(self.title().to_string()))
307        } else {
308            match page.get("revisions") {
309                Some(revisions) => {
310                    let revision = &revisions[0];
311                    let _ =
312                        self.baserevid.set(revision["revid"].as_u64().unwrap());
313                    Ok(revision["slots"]["main"]["content"]
314                        .as_str()
315                        .unwrap()
316                        .to_string())
317                }
318                None => {
319                    // Most likely invalid title, either way revision
320                    // doesn't exist
321                    Err(Error::PageDoesNotExist(self.title().to_string()))
322                }
323            }
324        }
325    }
326
327    /// Save the page using the specified HTML
328    pub async fn save<S: Into<Saveable>>(
329        self,
330        edit: S,
331        opts: &SaveOptions,
332    ) -> Result<(Page, EditResponse)> {
333        let mut exists: Option<bool> = None;
334        if self.bot.config.respect_nobots {
335            // Check {{nobots}} using existing wikicode
336            match self.html().await {
337                Ok(html) => {
338                    exists = Some(true);
339                    self.nobot_check(html)?;
340                }
341                Err(Error::PageDoesNotExist(_)) => {
342                    exists = Some(false);
343                }
344                Err(error) => {
345                    return Err(error);
346                }
347            }
348        } else if self.info.initialized() {
349            exists = Some(self.exists().await?);
350        }
351
352        let edit = edit.into();
353        let wikitext = match edit {
354            Saveable::Html(html) => {
355                self.bot.parsoid.transform_to_wikitext(&html).await?
356            }
357            Saveable::Wikitext(wikitext) => wikitext,
358        };
359
360        let mut params: Vec<(&'static str, String)> = vec![
361            ("action", "edit".to_string()),
362            ("title", self.title().to_string()),
363            ("text", wikitext),
364            ("summary", opts.summary.to_string()),
365        ];
366
367        // Edit conflict detection
368        if let Some(revid) = self.baserevid.get() {
369            params.push(("baserevid", revid.to_string()));
370        }
371        // Even more basic edit conflict detection if we already have it
372        match exists {
373            Some(true) => {
374                // Exists, don't create a new page
375                params.push(("nocreate", "1".to_string()));
376            }
377            Some(false) => {
378                // Missing, only create a new page
379                params.push(("createonly", "1".to_string()));
380            }
381            None => {} // May or may not exist
382        }
383
384        if let Some(section) = &opts.section {
385            params.push(("section", section.to_string()));
386        }
387        if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
388            params.push(("bot", "1".to_string()));
389        }
390        if !opts.tags.is_empty() {
391            params.push(("tags", opts.tags.join("|")));
392        }
393        if let Some(minor) = opts.minor {
394            if minor {
395                params.push(("minor", "1".to_string()));
396            } else {
397                params.push(("notminor", "1".to_string()));
398            }
399        }
400
401        let resp: Value = self
402            .with_save_lock(async {
403                info!("Saving [[{}]]", self.title());
404                self.bot.api.post_with_token("csrf", &params).await
405            })
406            .await?;
407
408        self.page_from_response(resp)
409    }
410
411    /// Reverses edits to revision IDs `from` through `to`.
412    /// If `to` is passed None, only one revision specified in `from` will be undone.
413    pub async fn undo(
414        self,
415        from: u64,
416        to: Option<u64>,
417        opts: &SaveOptions,
418    ) -> Result<(Page, EditResponse)> {
419        if self.bot.config.respect_nobots {
420            let html = self.html().await?;
421            self.nobot_check(html)?;
422        } else if self.info.initialized() && self.exists().await? {
423            return Err(Error::PageDoesNotExist(self.title().to_string()));
424        }
425
426        let mut params: Vec<(&'static str, String)> = vec![
427            ("action", "edit".to_string()),
428            ("title", self.title().to_string()),
429            ("undo", from.to_string()),
430            ("summary", opts.summary.to_string()),
431            ("nocreate", "1".to_string()), // undo means that the target page must exist.
432        ];
433
434        // Edit conflict detection
435        if let Some(revid) = self.baserevid.get() {
436            params.push(("baserevid", revid.to_string()));
437        }
438
439        if let Some(to) = to {
440            params.push(("undoafter", to.to_string()));
441        }
442        if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
443            params.push(("bot", "1".to_string()));
444        }
445        if !opts.tags.is_empty() {
446            params.push(("tags", opts.tags.join("|")));
447        }
448        if let Some(minor) = opts.minor {
449            if minor {
450                params.push(("minor", "1".to_string()));
451            } else {
452                params.push(("notminor", "1".to_string()));
453            }
454        }
455
456        let resp: Value = self
457            .with_save_lock(async {
458                info!("Undoing edit [[{}]]", self.title());
459                self.bot.api.post_with_token("csrf", &params).await
460            })
461            .await?;
462
463        self.page_from_response(resp)
464    }
465
466    /// From the response, return a Page containing the edited content.
467    fn page_from_response(self, resp: Value) -> Result<(Page, EditResponse)> {
468        match resp["edit"]["result"].as_str() {
469            Some("Success") => {
470                let edit_response: EditResponse =
471                    serde_json::from_value(resp["edit"].clone())?;
472                if !edit_response.nochange {
473                    let page = Page {
474                        bot: self.bot,
475                        title: self.title,
476                        title_text: self.title_text,
477                        info: Default::default(),
478                        baserevid: OnceCell::from(
479                            edit_response.newrevid.unwrap(),
480                        ),
481                    };
482                    Ok((page, edit_response))
483                } else {
484                    Ok((self, edit_response))
485                }
486            }
487            // Some legacy code might return "result": "Failure" but the
488            // structure is totally unspecified, so we're best off just
489            // passing the entire blob into the error in the hope it
490            // contains some clue.
491            _ => Err(Error::UnknownSaveFailure(resp)),
492        }
493    }
494
495    /// Check {{nobots}} using existing wikicode
496    fn nobot_check(&self, html: ImmutableWikicode) -> Result<()> {
497        let username = self
498            .bot
499            .config
500            .username
501            .clone()
502            .unwrap_or_else(|| "unknown".to_string());
503        if !crate::utils::nobots(&html, &username)? {
504            return Err(Error::Nobots);
505        }
506        Ok(())
507    }
508
509    async fn with_save_lock<F: Future<Output = T>, T>(&self, action: F) -> T {
510        // Get the save timer lock. Will be released once we're finished saving
511        let _save_lock = if let Some(save_timer) = &self.bot.state.save_timer {
512            let mut save_lock = save_timer.lock().await;
513            // TODO: would be nice if we could output a sleep message here, but we
514            // don't actually know whether we need to sleep or not.
515            save_lock.tick().await;
516            Some(save_lock)
517        } else {
518            None
519        };
520
521        action.await
522    }
523}
524
525impl Display for Page {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        f.write_str(self.title())
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use std::time::{Duration, SystemTime};
534
535    use crate::tests::{has_userright, is_authenticated, testwp};
536    use crate::Error;
537
538    use super::*;
539
540    #[tokio::test]
541    async fn test_exists() {
542        let bot = testwp().await;
543        let page = bot.page("Main Page").unwrap();
544        assert!(page.exists().await.unwrap());
545        let page2 = bot.page("DoesNotExistPlease").unwrap();
546        assert!(!page2.exists().await.unwrap());
547    }
548
549    #[tokio::test]
550    async fn test_title() {
551        let bot = testwp().await;
552        // Note the trailing space
553        let page = bot.page("Main Page ").unwrap();
554        assert_eq!(page.title(), "Main Page");
555        assert_eq!(page.as_title().dbkey(), "Main_Page");
556    }
557
558    #[tokio::test]
559    async fn test_get_redirect_target() {
560        let bot = testwp().await;
561        let redir = bot.page("Mwbot-rs/Redirect").unwrap();
562        let target = redir.redirect_target().await.unwrap().unwrap();
563        // "Redirect" points to "Main Page"
564        assert_eq!(target.title(), "Main Page");
565        // "Main Page" is not a redirect
566        assert!(target.redirect_target().await.unwrap().is_none());
567    }
568
569    #[tokio::test]
570    async fn test_get_content() {
571        let bot = testwp().await;
572        let page = bot.page("Main Page").unwrap();
573        let html = page.html().await.unwrap().into_mutable();
574        assert_eq!(html.title().unwrap(), "Main Page".to_string());
575        assert_eq!(
576            html.select_first("b").unwrap().text_contents(),
577            "test wiki".to_string()
578        );
579        let wikitext = page.wikitext().await.unwrap();
580        assert!(wikitext.contains("'''test wiki'''"));
581    }
582
583    #[tokio::test]
584    async fn test_set_baserevid() {
585        let bot = testwp().await;
586        let page = bot.page("Main Page").unwrap();
587        assert!(page.baserevid.get().is_none());
588        page.info().await.unwrap();
589        assert!(page.baserevid.get().is_some());
590    }
591
592    #[tokio::test]
593    async fn test_missing_page() {
594        let bot = testwp().await;
595        let page = bot.page("DoesNotExistPlease").unwrap();
596        let err = page.html().await.unwrap_err();
597        match err {
598            Error::PageDoesNotExist(page) => {
599                assert_eq!(&page, "DoesNotExistPlease")
600            }
601            err => {
602                panic!("Unexpected error: {err:?}")
603            }
604        }
605        let err2 = page.wikitext().await.unwrap_err();
606        match err2 {
607            Error::PageDoesNotExist(page) => {
608                assert_eq!(&page, "DoesNotExistPlease")
609            }
610            err => {
611                panic!("Unexpected error: {err:?}")
612            }
613        }
614    }
615
616    #[tokio::test]
617    async fn test_save() {
618        if !is_authenticated() {
619            return;
620        }
621
622        let bot = testwp().await;
623        let wikitext = format!(
624            "It has been {} seconds since the epoch.",
625            SystemTime::now()
626                .duration_since(SystemTime::UNIX_EPOCH)
627                .unwrap()
628                .as_secs()
629        );
630        let mut retries = 0;
631        loop {
632            let page = bot.page("mwbot-rs/Save").unwrap();
633            let resp = page
634                .save(
635                    wikitext.to_string(),
636                    &SaveOptions::summary("Test suite edit"),
637                )
638                .await;
639            match resp {
640                Ok(resp) => {
641                    assert_eq!(&resp.1.title, "Mwbot-rs/Save");
642                    return;
643                }
644                Err(Error::EditConflict) => {
645                    if retries > 5 {
646                        panic!("hit more than 5 edit conflicts");
647                    }
648                    retries += 1;
649                    tokio::time::sleep(Duration::from_secs(5)).await;
650                    continue;
651                }
652                Err(ref err) => {
653                    dbg!(&resp);
654                    panic!("{}", err);
655                }
656            }
657        }
658    }
659
660    #[tokio::test]
661    async fn test_undo() {
662        if !is_authenticated() {
663            return;
664        }
665
666        let bot = testwp().await;
667        let page = bot.page("mwbot-rs/Undo").unwrap();
668        let wikitext = format!(
669            "It has been {} seconds since the epoch.",
670            SystemTime::now()
671                .duration_since(SystemTime::UNIX_EPOCH)
672                .unwrap()
673                .as_secs()
674        );
675        let (page, _) = page
676            .save(wikitext, &SaveOptions::summary("Test suite edit"))
677            .await
678            .unwrap();
679
680        let revision_id = page.info().await.unwrap().lastrevid.unwrap();
681
682        let (page, _) = page
683            .undo(revision_id, None, &SaveOptions::summary("Test suite edit"))
684            .await
685            .unwrap();
686        let undoed_wikitext = page.wikitext().await.unwrap();
687
688        assert_eq!(undoed_wikitext, "This page is used to test undo.");
689    }
690
691    #[tokio::test]
692    async fn test_protected() {
693        if !is_authenticated() {
694            return;
695        }
696
697        let bot = testwp().await;
698        let page = bot.page("mwbot-rs/Protected").unwrap();
699        let wikitext = "Wait, I can edit this page?".to_string();
700        let error = page
701            .save(wikitext, &SaveOptions::summary("Test suite edit"))
702            .await
703            .unwrap_err();
704        dbg!(&error);
705        assert!(matches!(error, Error::ProtectedPage));
706    }
707
708    #[tokio::test]
709    async fn test_spamfilter() {
710        let bot = testwp().await;
711        if !is_authenticated() || !has_userright(&bot, "sboverride").await {
712            return;
713        }
714
715        let page = bot.page("mwbot-rs/SpamBlacklist").unwrap();
716        let wikitext = "https://bitly.com/12345".to_string();
717        let error = page
718            .save(wikitext, &SaveOptions::summary("Test suite edit"))
719            .await
720            .unwrap_err();
721        dbg!(&error);
722        if let Error::SpamFilter { matches, .. } = error {
723            assert_eq!(matches, vec!["bitly.com/1".to_string()])
724        } else {
725            panic!("{error:?} doesn't match")
726        }
727    }
728
729    #[tokio::test]
730    async fn test_partialblock() {
731        if !is_authenticated() {
732            return;
733        }
734        let bot = testwp().await;
735        let page = bot.page("Mwbot-rs/Partially blocked").unwrap();
736        let error = page
737            .save(
738                "I shouldn't be able to edit this".to_string(),
739                &SaveOptions::summary("Test suite edit"),
740            )
741            .await
742            .unwrap_err();
743        dbg!(&error);
744        if let Error::PartiallyBlocked { info, .. } = error {
745            assert!(info.starts_with("<strong>Your username or IP address is blocked from doing this"));
746        } else {
747            panic!("{error:?} doesn't match");
748        }
749    }
750
751    /// Regression test to verify we don't panic on invalid titles
752    /// https://gitlab.com/mwbot-rs/mwbot/-/issues/33
753    ///
754    /// Mostly moot now that we have proper title validation
755    #[tokio::test]
756    async fn test_invalidtitle() {
757        let bot = testwp().await;
758        // Should return an error
759        let err = bot.page("<invalid title>").unwrap_err();
760        assert!(matches!(err, Error::InvalidTitle(_)));
761        let err = bot.page("Special:BlankPage").unwrap_err();
762        assert!(matches!(err, Error::InvalidPage));
763    }
764
765    #[tokio::test]
766    async fn test_editconflict() {
767        if !is_authenticated() {
768            return;
769        }
770        let bot = testwp().await;
771        let page = bot.page("mwbot-rs/Edit conflict").unwrap();
772        // Fake a older baserevid in
773        page.baserevid.set(498547).unwrap();
774        let err = page
775            .save(
776                "This should fail",
777                &SaveOptions::summary("this should fail"),
778            )
779            .await
780            .unwrap_err();
781        dbg!(&err);
782        assert!(matches!(err, Error::EditConflict));
783    }
784
785    #[tokio::test]
786    async fn test_associated_page() {
787        let bot = testwp().await;
788        let page = bot.page("Main Page").unwrap();
789        assert_eq!(
790            page.associated_page().await.unwrap().title(),
791            "Talk:Main Page"
792        );
793    }
794
795    #[tokio::test]
796    async fn test_nobots() {
797        if !is_authenticated() {
798            return;
799        }
800        let bot = testwp().await;
801        let page = bot.page("Mwbot-rs/Nobots").unwrap();
802        let error = page
803            .save(
804                "This edit should not go through due to the {{nobots}} template".to_string(),
805                &SaveOptions::summary("Test suite edit"),
806            )
807            .await
808            .unwrap_err();
809        assert!(matches!(error, Error::Nobots));
810    }
811
812    #[tokio::test]
813    async fn test_display() {
814        let bot = testwp().await;
815        let page = bot.page("Main Page").unwrap();
816        assert_eq!(format!("{}", page), "Main Page");
817    }
818
819    #[tokio::test]
820    async fn test_touched() {
821        let bot = testwp().await;
822        assert!(bot
823            .page("Main Page")
824            .unwrap()
825            .touched()
826            .await
827            .unwrap()
828            .is_some());
829    }
830
831    #[tokio::test]
832    async fn test_latest_revision_id() {
833        let bot = testwp().await;
834        assert!(bot
835            .page("Main Page")
836            .unwrap()
837            .latest_revision_id()
838            .await
839            .unwrap()
840            .is_some());
841    }
842
843    #[tokio::test]
844    async fn test_language_links() {
845        let bot = testwp().await;
846        assert_eq!(
847            bot.page("Mwbot-rs/Langlink")
848                .unwrap()
849                .language_links()
850                .await
851                .unwrap()
852                .unwrap()
853                .into_iter()
854                .collect::<Vec<_>>(),
855            [("en".to_string(), "Stick style".to_string())]
856        );
857    }
858
859    #[tokio::test]
860    async fn test_categories() {
861        let bot = testwp().await;
862        assert_eq!(
863            bot.page("Mwbot-rs/Categorized")
864                .unwrap()
865                .categories()
866                .await
867                .unwrap()
868                .unwrap(),
869            ["Category:Mwbot-rs"]
870        );
871    }
872
873    #[tokio::test]
874    async fn test_templates() {
875        let bot = testwp().await;
876        assert!(bot
877            .page("Mwbot-rs/Transcluded")
878            .unwrap()
879            .templates(None)
880            .await
881            .unwrap()
882            .unwrap()
883            .contains(&"Main Page".to_string()));
884    }
885}