diff --git a/src/lib.rs b/src/lib.rs index 28f9dc9..c07ca09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,10 +18,13 @@ mod util; mod watches; // things we want in the crate namespace +use optional_optional_user::OptionalOptionalUser; use templates::*; use users::User; use watches::{templates::*, ShowKind, Watch}; +use crate::watches::handlers::get_watch; + type AuthContext = axum_login::extractors::AuthContext>; @@ -46,6 +49,8 @@ pub async fn app(db_pool: sqlx::SqlitePool, session_secret: &[u8]) -> axum::Rout .route("/login", get(get_login).post(post_login)) .route("/logout", get(get_logout).post(post_logout)) .route("/watches", get(get_watches)) + .route("/watch", get(get_watch)) + .route("/watch/:id", get(get_watch)) .route("/search", get(get_search_watch).post(post_search_watch)) .route( "/add", @@ -62,3 +67,39 @@ pub async fn app(db_pool: sqlx::SqlitePool, session_secret: &[u8]) -> axum::Rout .layer(session_layer) .with_state(db_pool) } + +//-************************************************************************ +// tests for the proc macro for optional user +//-************************************************************************ +#[cfg(test)] +mod test { + use super::{CreateUserSuccess, MainPage, OptionalOptionalUser, User}; + + #[test] + fn main_page_has_optional_user() { + assert!(MainPage::default().has_optional_user()); + } + + #[test] + fn signup_success_has_no_optional_user() { + assert!(!CreateUserSuccess::default().has_optional_user()); + } + + #[test] + fn user_is_not_optional() { + #[derive(Default, OptionalOptionalUser)] + struct TestThing { + user: User, + } + assert!(!TestThing::default().has_optional_user()); + } + + #[test] + fn user_is_not_user() { + #[derive(Default, OptionalOptionalUser)] + struct TestThing { + user: Option, + } + assert!(!TestThing::default().has_optional_user()); + } +} diff --git a/src/signup.rs b/src/signup.rs index 171a643..a2e73f0 100644 --- a/src/signup.rs +++ b/src/signup.rs @@ -81,10 +81,9 @@ pub async fn post_create_user( if password != verify { return Err(CreateUserErrorKind::PasswordMismatch.into()); } - - let password = password.trim(); + let pwlen = password.graphemes(true).size_hint().1.unwrap_or(0); let password = password.as_bytes(); - if !(4..=50).contains(&password.len()) { + if !(4..=50).contains(&pwlen) { return Err(CreateUserErrorKind::BadPassword.into()); } @@ -352,6 +351,35 @@ mod test { assert_eq!(&expected, body); } + #[tokio::test] + async fn multibyte_password_too_short() { + let pw = "🤡"; + // min length is 4 + assert_eq!(pw.len(), 4); + + let pool = get_db_pool().await; + let server = server_with_pool(&pool).await; + let form = + format!("username=test_user&displayname=Test+User&password={pw}&pw_verify={pw}"); + let body = massage(&form); + + let resp = server + .post("/signup") + // failure to sign up is not failure to submit the request + .expect_success() + .bytes(body) + .content_type(FORM_CONTENT_TYPE) + .await; + + // no user in db + let user = User::try_get("test_user", &pool).await; + assert!(user.is_err()); + + let body = std::str::from_utf8(resp.bytes()).unwrap(); + let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string(); + assert_eq!(&expected, body); + } + #[tokio::test] async fn username_short() { let pool = get_db_pool().await; diff --git a/src/templates.rs b/src/templates.rs index 6fc1b88..664533f 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,8 +1,7 @@ use askama::Template; -use optional_optional_user::OptionalOptionalUser; use serde::{Deserialize, Serialize}; -use crate::User; +use crate::{OptionalOptionalUser, User}; #[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)] #[template(path = "signup.html")] @@ -47,36 +46,3 @@ pub struct LogoutPost; pub struct MainPage { pub user: Option, } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn main_page_has_optional_user() { - assert!(MainPage::default().has_optional_user()); - } - - #[test] - fn signup_success_has_no_optional_user() { - assert!(!CreateUserSuccess::default().has_optional_user()); - } - - #[test] - fn user_is_not_optional() { - #[derive(Default, OptionalOptionalUser)] - struct TestThing { - user: User, - } - assert!(!TestThing::default().has_optional_user()); - } - - #[test] - fn user_is_not_user() { - #[derive(Default, OptionalOptionalUser)] - struct TestThing { - user: Option, - } - assert!(!TestThing::default().has_optional_user()); - } -} diff --git a/src/util.rs b/src/util.rs index 466660c..022c0f8 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,7 @@ use std::{error::Error, ops::Range}; +use unicode_segmentation::UnicodeSegmentation; + pub fn validate_optional_length( opt: &Option, len_range: Range, @@ -7,7 +9,8 @@ pub fn validate_optional_length( ) -> Result, E> { if let Some(opt) = opt { let opt = opt.trim(); - if !len_range.contains(&opt.len()) { + let len = opt.graphemes(true).size_hint().1.unwrap(); + if !len_range.contains(&len) { Err(err) } else { Ok(Some(opt.to_string())) diff --git a/src/watches/handlers.rs b/src/watches/handlers.rs index 67e55f5..d220ee5 100644 --- a/src/watches/handlers.rs +++ b/src/watches/handlers.rs @@ -1,7 +1,5 @@ -use std::path::Path; - use axum::{ - extract::{Form, Query, State}, + extract::{Form, Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; @@ -9,6 +7,7 @@ use serde::Deserialize; use sqlx::{query_as, SqlitePool}; use uuid::Uuid; +use super::templates::{GetSearchWatches, GetWatch}; use crate::{AuthContext, GetWatches, ShowKind, User, Watch}; //-************************************************************************ @@ -18,6 +17,8 @@ use crate::{AuthContext, GetWatches, ShowKind, User, Watch}; const GET_WATCHES_QUERY: &str = "select * from watches left join witch_watch on $1 = witch_watch.witch and watches.id = witch_watch.watch"; +const GET_WATCH_QUERY: &str = "select * from watches where id = $1"; + //-************************************************************************ // Error types for Watch creation //-************************************************************************ @@ -41,11 +42,34 @@ impl IntoResponse for WatchAddError { } } +//-************************************************************************ +// Types for receiving arguments from search queries +//-************************************************************************ + #[derive(Debug, Default, Clone, Deserialize)] pub struct SimpleSearchQuery { search: String, } +#[derive(Debug, Default, Clone, Deserialize)] +pub struct FullSearchQuery { + pub title: Option, + pub kind: Option, + pub year: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub enum SearchQuery { + Full(FullSearchQuery), + Simple(SimpleSearchQuery), +} + +impl Default for SearchQuery { + fn default() -> Self { + SearchQuery::Simple(SimpleSearchQuery::default()) + } +} + //-************************************************************************ // handlers //-************************************************************************ @@ -57,12 +81,34 @@ pub async fn put_add_watch() {} pub async fn post_add_watch() {} /// A single Watch -pub async fn get_watch() {} +pub async fn get_watch( + auth: AuthContext, + watch: Option>, + State(pool): State, +) -> impl IntoResponse { + let id = if let Some(Path(id)) = watch { + id + } else { + "".to_string() + }; + let id = id.trim(); + + let watch: Option = query_as(GET_WATCH_QUERY) + .bind(id) + .fetch_one(&pool) + .await + .ok(); + + GetWatch { + watch, + user: auth.current_user, + } +} /// everything the user has saved pub async fn get_watches(auth: AuthContext, State(pool): State) -> impl IntoResponse { - let user = &auth.current_user; - let watches: Vec = if user.is_some() { + let user = auth.current_user; + let watches: Vec = if (user).is_some() { query_as(GET_WATCHES_QUERY) .bind(user.as_ref().unwrap().id) .fetch_all(&pool) @@ -72,22 +118,32 @@ pub async fn get_watches(auth: AuthContext, State(pool): State) -> i vec![] }; - GetWatches { - watches, - user: user.clone(), - } + GetWatches { watches, user } } pub async fn get_search_watch( - _auth: AuthContext, - State(pool): State, - search: Option>, -) { + auth: AuthContext, + State(_pool): State, + search: Option>, +) -> impl IntoResponse { + use SearchQuery::*; let search = match search { - Some(Query(SimpleSearchQuery { search })) => search, + Some(Query(Simple(SimpleSearchQuery { search }))) => search, + Some(Query(Full(q))) => { + // obviously this is dumb + format!("{q:?}") + } None => "".to_owned(), }; - let search = search.trim(); + let search = search.trim().to_string(); + + let user = auth.current_user; + + GetSearchWatches { + watches: vec![], + user, + search, + } } pub async fn post_search_watch() {} diff --git a/src/watches/templates.rs b/src/watches/templates.rs index 3e913f2..1f4525d 100644 --- a/src/watches/templates.rs +++ b/src/watches/templates.rs @@ -1,8 +1,7 @@ use askama::Template; -use optional_optional_user::OptionalOptionalUser; use serde::{Deserialize, Serialize}; -use crate::{User, Watch}; +use crate::{OptionalOptionalUser, User, Watch}; #[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)] #[template(path = "get_watches.html")] @@ -13,4 +12,15 @@ pub struct GetWatches { #[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)] #[template(path = "get_search_watches.html")] -pub struct GetSearchWatches {} +pub struct GetSearchWatches { + pub watches: Vec, + pub user: Option, + pub search: String, +} + +#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)] +#[template(path = "get_watch.html")] +pub struct GetWatch { + pub watch: Option, + pub user: Option, +} diff --git a/templates/get_search_watches.html b/templates/get_search_watches.html index e69de29..6835865 100644 --- a/templates/get_search_watches.html +++ b/templates/get_search_watches.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% import "macros.html" as m %} + +{% block title %}Welcome to Witch Watch, Bish{% endblock %} + +{% block content %} + +

Whatcha Watchin?

+ +
{{self.search}}
+ +
+
    + {% for watch in watches %} +
  • {{watch.title}} -- {% call m::get_or_default(watch.release_date, "when??") %}:
  • + {% endfor %} +
+
+

+

+ +
+ +
+

+ +{% endblock %} diff --git a/templates/get_watch.html b/templates/get_watch.html new file mode 100644 index 0000000..4af344f --- /dev/null +++ b/templates/get_watch.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% import "macros.html" as m %} + +{% block title %}Welcome to Witch Watch, Bish{% endblock %} + +{% block content %} + +

Whatcha Watchin?

+ +{% match watch %} + +{% when Some with (watch) %} + +
+ {{watch.title}} -- {% call m::get_or_default(watch.release_date, "when??") %} +
+ +{% else %} + +
Sorry, maybe you meant to search for something?
+ +{% endmatch %} + + +

+

+ +
+ +
+

+ +{% endblock %}