checkpoint merge from watches
This commit is contained in:
commit
139840cb4a
8 changed files with 222 additions and 58 deletions
41
src/lib.rs
41
src/lib.rs
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
|
@ -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() {}
|
||||||
|
|
|
@ -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>,
|
||||||
|
}
|
||||||
|
|
|
@ -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
33
templates/get_watch.html
Normal 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 %}
|
Loading…
Reference in a new issue