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|protection|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    #[ignore = "T391120"]
662    async fn test_undo() {
663        if !is_authenticated() {
664            return;
665        }
666
667        let bot = testwp().await;
668        let page = bot.page("mwbot-rs/Undo").unwrap();
669        let wikitext = format!(
670            "It has been {} seconds since the epoch.",
671            SystemTime::now()
672                .duration_since(SystemTime::UNIX_EPOCH)
673                .unwrap()
674                .as_secs()
675        );
676        let (page, _) = page
677            .save(wikitext, &SaveOptions::summary("Test suite edit"))
678            .await
679            .unwrap();
680
681        let revision_id = page.info().await.unwrap().lastrevid.unwrap();
682
683        let (page, _) = page
684            .undo(revision_id, None, &SaveOptions::summary("Test suite edit"))
685            .await
686            .unwrap();
687        let undoed_wikitext = page.wikitext().await.unwrap();
688
689        assert_eq!(undoed_wikitext, "This page is used to test undo.");
690    }
691
692    #[tokio::test]
693    async fn test_protected() {
694        if !is_authenticated() {
695            return;
696        }
697
698        let bot = testwp().await;
699        let page = bot.page("mwbot-rs/Protected").unwrap();
700        let wikitext = "Wait, I can edit this page?".to_string();
701        let error = page
702            .save(wikitext, &SaveOptions::summary("Test suite edit"))
703            .await
704            .unwrap_err();
705        dbg!(&error);
706        assert!(matches!(error, Error::ProtectedPage));
707    }
708
709    #[tokio::test]
710    async fn test_spamfilter() {
711        let bot = testwp().await;
712        if !is_authenticated() || !has_userright(&bot, "sboverride").await {
713            return;
714        }
715
716        let page = bot.page("mwbot-rs/SpamBlacklist").unwrap();
717        let wikitext = "https://bitly.com/12345".to_string();
718        let error = page
719            .save(wikitext, &SaveOptions::summary("Test suite edit"))
720            .await
721            .unwrap_err();
722        dbg!(&error);
723        if let Error::SpamFilter { matches, .. } = error {
724            assert_eq!(matches, vec!["bitly.com/1".to_string()])
725        } else {
726            panic!("{error:?} doesn't match")
727        }
728    }
729
730    #[tokio::test]
731    async fn test_partialblock() {
732        if !is_authenticated() {
733            return;
734        }
735        let bot = testwp().await;
736        let page = bot.page("Mwbot-rs/Partially blocked").unwrap();
737        let error = page
738            .save(
739                "I shouldn't be able to edit this".to_string(),
740                &SaveOptions::summary("Test suite edit"),
741            )
742            .await
743            .unwrap_err();
744        dbg!(&error);
745        if let Error::PartiallyBlocked { info, .. } = error {
746            assert!(info.starts_with("<strong>Your username or IP address is blocked from doing this"));
747        } else {
748            panic!("{error:?} doesn't match");
749        }
750    }
751
752    /// Regression test to verify we don't panic on invalid titles
753    /// https://gitlab.com/mwbot-rs/mwbot/-/issues/33
754    ///
755    /// Mostly moot now that we have proper title validation
756    #[tokio::test]
757    async fn test_invalidtitle() {
758        let bot = testwp().await;
759        // Should return an error
760        let err = bot.page("<invalid title>").unwrap_err();
761        assert!(matches!(err, Error::InvalidTitle(_)));
762        let err = bot.page("Special:BlankPage").unwrap_err();
763        assert!(matches!(err, Error::InvalidPage));
764    }
765
766    #[tokio::test]
767    async fn test_editconflict() {
768        if !is_authenticated() {
769            return;
770        }
771        let bot = testwp().await;
772        let page = bot.page("mwbot-rs/Edit conflict").unwrap();
773        // Fake a older baserevid in
774        page.baserevid.set(498547).unwrap();
775        let err = page
776            .save(
777                "This should fail",
778                &SaveOptions::summary("this should fail"),
779            )
780            .await
781            .unwrap_err();
782        dbg!(&err);
783        assert!(matches!(err, Error::EditConflict));
784    }
785
786    #[tokio::test]
787    async fn test_associated_page() {
788        let bot = testwp().await;
789        let page = bot.page("Main Page").unwrap();
790        assert_eq!(
791            page.associated_page().await.unwrap().title(),
792            "Talk:Main Page"
793        );
794    }
795
796    #[tokio::test]
797    async fn test_nobots() {
798        if !is_authenticated() {
799            return;
800        }
801        let bot = testwp().await;
802        let page = bot.page("Mwbot-rs/Nobots").unwrap();
803        let error = page
804            .save(
805                "This edit should not go through due to the {{nobots}} template".to_string(),
806                &SaveOptions::summary("Test suite edit"),
807            )
808            .await
809            .unwrap_err();
810        assert!(matches!(error, Error::Nobots));
811    }
812
813    #[tokio::test]
814    async fn test_display() {
815        let bot = testwp().await;
816        let page = bot.page("Main Page").unwrap();
817        assert_eq!(format!("{}", page), "Main Page");
818    }
819
820    #[tokio::test]
821    async fn test_touched() {
822        let bot = testwp().await;
823        assert!(bot
824            .page("Main Page")
825            .unwrap()
826            .touched()
827            .await
828            .unwrap()
829            .is_some());
830    }
831
832    #[tokio::test]
833    async fn test_latest_revision_id() {
834        let bot = testwp().await;
835        assert!(bot
836            .page("Main Page")
837            .unwrap()
838            .latest_revision_id()
839            .await
840            .unwrap()
841            .is_some());
842    }
843
844    #[tokio::test]
845    async fn test_language_links() {
846        let bot = testwp().await;
847        assert_eq!(
848            bot.page("Mwbot-rs/Langlink")
849                .unwrap()
850                .language_links()
851                .await
852                .unwrap()
853                .unwrap()
854                .into_iter()
855                .collect::<Vec<_>>(),
856            [("en".to_string(), "Stick style".to_string())]
857        );
858    }
859
860    #[tokio::test]
861    async fn test_categories() {
862        let bot = testwp().await;
863        assert_eq!(
864            bot.page("Mwbot-rs/Categorized")
865                .unwrap()
866                .categories()
867                .await
868                .unwrap()
869                .unwrap(),
870            ["Category:Mwbot-rs"]
871        );
872    }
873
874    #[tokio::test]
875    async fn test_templates() {
876        let bot = testwp().await;
877        assert!(bot
878            .page("Mwbot-rs/Transcluded")
879            .unwrap()
880            .templates(None)
881            .await
882            .unwrap()
883            .unwrap()
884            .contains(&"Main Page".to_string()));
885    }
886}