checkpoint merge from watches

This commit is contained in:
Joe Ardent 2023-06-15 16:19:44 -07:00
commit 139840cb4a
8 changed files with 222 additions and 58 deletions

View file

@ -18,10 +18,13 @@ mod util;
mod watches; mod watches;
// things we want in the crate namespace // things we want in the crate namespace
use optional_optional_user::OptionalOptionalUser;
use templates::*; use templates::*;
use users::User; use users::User;
use watches::{templates::*, ShowKind, Watch}; use watches::{templates::*, ShowKind, Watch};
use crate::watches::handlers::get_watch;
type AuthContext = type AuthContext =
axum_login::extractors::AuthContext<uuid::Uuid, User, axum_login::SqliteStore<User>>; axum_login::extractors::AuthContext<uuid::Uuid, User, axum_login::SqliteStore<User>>;
@ -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("/login", get(get_login).post(post_login))
.route("/logout", get(get_logout).post(post_logout)) .route("/logout", get(get_logout).post(post_logout))
.route("/watches", get(get_watches)) .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("/search", get(get_search_watch).post(post_search_watch))
.route( .route(
"/add", "/add",
@ -62,3 +67,39 @@ pub async fn app(db_pool: sqlx::SqlitePool, session_secret: &[u8]) -> axum::Rout
.layer(session_layer) .layer(session_layer)
.with_state(db_pool) .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<bool>,
}
assert!(!TestThing::default().has_optional_user());
}
}

View file

@ -81,10 +81,9 @@ pub async fn post_create_user(
if password != verify { if password != verify {
return Err(CreateUserErrorKind::PasswordMismatch.into()); return Err(CreateUserErrorKind::PasswordMismatch.into());
} }
let pwlen = password.graphemes(true).size_hint().1.unwrap_or(0);
let password = password.trim();
let password = password.as_bytes(); let password = password.as_bytes();
if !(4..=50).contains(&password.len()) { if !(4..=50).contains(&pwlen) {
return Err(CreateUserErrorKind::BadPassword.into()); return Err(CreateUserErrorKind::BadPassword.into());
} }
@ -352,6 +351,35 @@ mod test {
assert_eq!(&expected, body); 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] #[tokio::test]
async fn username_short() { async fn username_short() {
let pool = get_db_pool().await; let pool = get_db_pool().await;

View file

@ -1,8 +1,7 @@
use askama::Template; use askama::Template;
use optional_optional_user::OptionalOptionalUser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::User; use crate::{OptionalOptionalUser, User};
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)] #[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "signup.html")] #[template(path = "signup.html")]
@ -47,36 +46,3 @@ pub struct LogoutPost;
pub struct MainPage { pub struct MainPage {
pub user: Option<User>, pub user: Option<User>,
} }
#[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<bool>,
}
assert!(!TestThing::default().has_optional_user());
}
}

View file

@ -1,5 +1,7 @@
use std::{error::Error, ops::Range}; use std::{error::Error, ops::Range};
use unicode_segmentation::UnicodeSegmentation;
pub fn validate_optional_length<E: Error>( pub fn validate_optional_length<E: Error>(
opt: &Option<String>, opt: &Option<String>,
len_range: Range<usize>, len_range: Range<usize>,
@ -7,7 +9,8 @@ pub fn validate_optional_length<E: Error>(
) -> Result<Option<String>, E> { ) -> Result<Option<String>, E> {
if let Some(opt) = opt { if let Some(opt) = opt {
let opt = opt.trim(); 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) Err(err)
} else { } else {
Ok(Some(opt.to_string())) Ok(Some(opt.to_string()))

View file

@ -1,7 +1,5 @@
use std::path::Path;
use axum::{ use axum::{
extract::{Form, Query, State}, extract::{Form, Path, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@ -9,6 +7,7 @@ use serde::Deserialize;
use sqlx::{query_as, SqlitePool}; use sqlx::{query_as, SqlitePool};
use uuid::Uuid; use uuid::Uuid;
use super::templates::{GetSearchWatches, GetWatch};
use crate::{AuthContext, GetWatches, ShowKind, User, Watch}; use crate::{AuthContext, GetWatches, ShowKind, User, Watch};
//-************************************************************************ //-************************************************************************
@ -18,6 +17,8 @@ use crate::{AuthContext, GetWatches, ShowKind, User, Watch};
const GET_WATCHES_QUERY: &str = const GET_WATCHES_QUERY: &str =
"select * from watches left join witch_watch on $1 = witch_watch.witch and watches.id = witch_watch.watch"; "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 // 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)] #[derive(Debug, Default, Clone, Deserialize)]
pub struct SimpleSearchQuery { pub struct SimpleSearchQuery {
search: String, search: String,
} }
#[derive(Debug, Default, Clone, Deserialize)]
pub struct FullSearchQuery {
pub title: Option<String>,
pub kind: Option<String>,
pub year: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub enum SearchQuery {
Full(FullSearchQuery),
Simple(SimpleSearchQuery),
}
impl Default for SearchQuery {
fn default() -> Self {
SearchQuery::Simple(SimpleSearchQuery::default())
}
}
//-************************************************************************ //-************************************************************************
// handlers // handlers
//-************************************************************************ //-************************************************************************
@ -57,12 +81,34 @@ pub async fn put_add_watch() {}
pub async fn post_add_watch() {} pub async fn post_add_watch() {}
/// A single Watch /// A single Watch
pub async fn get_watch() {} pub async fn get_watch(
auth: AuthContext,
watch: Option<Path<String>>,
State(pool): State<SqlitePool>,
) -> impl IntoResponse {
let id = if let Some(Path(id)) = watch {
id
} else {
"".to_string()
};
let id = id.trim();
let watch: Option<Watch> = query_as(GET_WATCH_QUERY)
.bind(id)
.fetch_one(&pool)
.await
.ok();
GetWatch {
watch,
user: auth.current_user,
}
}
/// everything the user has saved /// everything the user has saved
pub async fn get_watches(auth: AuthContext, State(pool): State<SqlitePool>) -> impl IntoResponse { pub async fn get_watches(auth: AuthContext, State(pool): State<SqlitePool>) -> impl IntoResponse {
let user = &auth.current_user; let user = auth.current_user;
let watches: Vec<Watch> = if user.is_some() { let watches: Vec<Watch> = if (user).is_some() {
query_as(GET_WATCHES_QUERY) query_as(GET_WATCHES_QUERY)
.bind(user.as_ref().unwrap().id) .bind(user.as_ref().unwrap().id)
.fetch_all(&pool) .fetch_all(&pool)
@ -72,22 +118,32 @@ pub async fn get_watches(auth: AuthContext, State(pool): State<SqlitePool>) -> i
vec![] vec![]
}; };
GetWatches { GetWatches { watches, user }
watches,
user: user.clone(),
}
} }
pub async fn get_search_watch( pub async fn get_search_watch(
_auth: AuthContext, auth: AuthContext,
State(pool): State<SqlitePool>, State(_pool): State<SqlitePool>,
search: Option<Query<SimpleSearchQuery>>, search: Option<Query<SearchQuery>>,
) { ) -> impl IntoResponse {
use SearchQuery::*;
let search = match search { 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(), 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() {} pub async fn post_search_watch() {}

View file

@ -1,8 +1,7 @@
use askama::Template; use askama::Template;
use optional_optional_user::OptionalOptionalUser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{User, Watch}; use crate::{OptionalOptionalUser, User, Watch};
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)] #[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "get_watches.html")] #[template(path = "get_watches.html")]
@ -13,4 +12,15 @@ pub struct GetWatches {
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)] #[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "get_search_watches.html")] #[template(path = "get_search_watches.html")]
pub struct GetSearchWatches {} pub struct GetSearchWatches {
pub watches: Vec<Watch>,
pub user: Option<User>,
pub search: String,
}
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq, OptionalOptionalUser)]
#[template(path = "get_watch.html")]
pub struct GetWatch {
pub watch: Option<Watch>,
pub user: Option<User>,
}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% import "macros.html" as m %}
{% block title %}Welcome to Witch Watch, Bish{% endblock %}
{% block content %}
<h1>Whatcha Watchin?</h1>
<div class="quicksearch-query">{{self.search}}</div>
<div class="watchlist">
<ul>
{% for watch in watches %}
<li><span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.release_date, "when??") %}: </li>
{% endfor %}
</ul>
</div>
<p>
<form action="/search" enctype="application/x-www-form-urlencoded" method="get">
<label for="search">Looking for something else to watch?</label>
<input type="text" name="search" id="search"></br>
<input type="submit" value="Let's go!">
</form>
</p>
{% endblock %}

33
templates/get_watch.html Normal file
View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% import "macros.html" as m %}
{% block title %}Welcome to Witch Watch, Bish{% endblock %}
{% block content %}
<h1>Whatcha Watchin?</h1>
{% match watch %}
{% when Some with (watch) %}
<div class="watch">
<span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.release_date, "when??") %}
</div>
{% else %}
<div class="no-watch-found">Sorry, maybe you meant to <a href="/search">search for something</a>?</div>
{% endmatch %}
<p>
<form action="/search" enctype="application/x-www-form-urlencoded" method="get">
<label for="search">quick search</label>
<input type="text" name="search" id="search"></br>
<input type="submit" value="Let's go!">
</form>
</p>
{% endblock %}