Skip to content
use actix_identity::IdentityMiddleware;
use actix_session::{SessionMiddleware, config::PersistentSession};
use actix_web::cookie::Cookie;
use actix_web::http::StatusCode;
use actix_web::test as aw_test;
use actix_web::{App, cookie::time::Duration, web};
use serde::Serialize;

use crate::auth::SESSION_COOKIE_NAME;
use crate::config::Config;
use crate::db::Database;

#[derive(Serialize)]
struct Signup<'a> {
    username: &'a str,
    email: &'a str,
    password: &'a str,
    invite: &'a str,
}

#[derive(Serialize)]
struct CreateInvite<'a> {
    invite_secret: &'a str,
}

#[derive(Serialize)]
struct Login<'a> {
    username: &'a str,
    password: &'a str,
}

async fn setup() -> (Database, Config, String) {
    let db_path = format!("/tmp/test_pear_it_{}.db", uuid::Uuid::new_v4());
    let db = Database::new(&db_path);
    db.init().await.expect("init db");
    let mut config = Config::new(8080, None, db_path.clone(), false);
    config.invite_secret = Some("test-invite-secret".to_string());
    db.create_invite("test-invite")
        .await
        .expect("create test invite");
    (db, config, db_path)
}

fn cleanup(path: &str) {
    for suffix in ["", "-shm", "-wal"] {
        let _ = std::fs::remove_file(format!("{path}{suffix}"));
    }
}

macro_rules! test_app {
    ($db:expr, $config:expr) => {
        aw_test::init_service(
            App::new()
                .wrap(IdentityMiddleware::default())
                .wrap(
                    SessionMiddleware::builder(
                        crate::db::session_store::SqliteSessionStore::new($db.clone()),
                        ($config).session_key.clone(),
                    )
                    .cookie_name(SESSION_COOKIE_NAME.to_string())
                    .session_lifecycle(PersistentSession::default().session_ttl(Duration::days(30)))
                    .build(),
                )
                .app_data(web::Data::new($db))
                .app_data(web::Data::new($config))
                .configure(crate::configure_routes),
        )
        .await
    };
}

fn session_token(resp: &actix_web::dev::ServiceResponse) -> Option<String> {
    resp.response()
        .cookies()
        .find(|c| c.name() == SESSION_COOKIE_NAME)
        .map(|c| c.value().to_string())
}

async fn body_string(resp: actix_web::dev::ServiceResponse) -> String {
    let bytes = aw_test::read_body(resp).await;
    String::from_utf8(bytes.to_vec()).expect("utf-8 body")
}

#[actix_web::test]
async fn test_signup_login_and_add_a_private_task() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db.clone(), config);

    // Sign up.
    let req = aw_test::TestRequest::post()
        .uri("/auth/signup")
        .insert_header(("HX-Request", "true"))
        .set_form(Signup {
            username: "alice",
            email: "alice@example.com",
            password: "password123",
            invite: "test-invite",
        })
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert!(resp.status().is_success());
    assert!(body_string(resp).await.contains("Account created"));

    // Log in and capture the session cookie.
    let req = aw_test::TestRequest::post()
        .uri("/auth/login")
        .insert_header(("HX-Request", "true"))
        .set_form(Login {
            username: "alice",
            password: "password123",
        })
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert!(resp.status().is_success());
    assert_eq!(
        resp.headers().get("HX-Redirect").unwrap(),
        "/",
        "successful login redirects home"
    );
    let token = session_token(&resp).expect("login sets a session cookie");
    let cookie = Cookie::new(SESSION_COOKIE_NAME, token);

    // Tasks stand alone and can have optional tags.
    let req = aw_test::TestRequest::post()
        .uri("/tickets")
        .cookie(cookie.clone())
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("title=Keep+this+private&new_tags=personal%2C+today")
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::SEE_OTHER);

    // Visitors see the done counter and login, never private task details.
    let req = aw_test::TestRequest::get().uri("/").to_request();
    let body = body_string(aw_test::call_service(&app, req).await).await;
    assert!(body.contains("0</strong>") && body.contains("tickets"));
    assert!(body.contains("me.silenlocatelli.ch"));
    assert!(body.contains("/auth/login"));
    assert!(!body.contains("Keep this private"));

    // Only its creator sees the task and its tags.
    let req = aw_test::TestRequest::get()
        .uri("/")
        .cookie(cookie)
        .to_request();
    let body = body_string(aw_test::call_service(&app, req).await).await;
    assert!(body.contains("Keep this private"));
    assert!(body.contains("#personal") && body.contains("#today"));

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_done_and_not_planned_are_distinct_closed_outcomes() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db.clone(), config);

    let req = aw_test::TestRequest::post()
        .uri("/auth/signup")
        .insert_header(("HX-Request", "true"))
        .set_form(Signup {
            username: "alice",
            email: "alice@example.com",
            password: "password123",
            invite: "test-invite",
        })
        .to_request();
    aw_test::call_service(&app, req).await;
    let req = aw_test::TestRequest::post()
        .uri("/auth/login")
        .insert_header(("HX-Request", "true"))
        .set_form(Login {
            username: "alice",
            password: "password123",
        })
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    let cookie = Cookie::new(
        SESSION_COOKIE_NAME,
        session_token(&resp).expect("session cookie"),
    );

    let user = db.get_user_by_username("alice").await.unwrap().unwrap();
    let req = aw_test::TestRequest::post()
        .uri("/tickets")
        .cookie(cookie.clone())
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("title=Write+the+outline&new_tags=writing%2C+important")
        .to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::SEE_OTHER
    );
    let req = aw_test::TestRequest::post()
        .uri("/tickets")
        .cookie(cookie.clone())
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("title=Maybe+learn+the+harp")
        .to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::SEE_OTHER
    );

    let tasks = db.visible_tickets(Some(&user.id)).await.unwrap();
    assert_eq!(tasks.len(), 2);
    let done_id = tasks
        .iter()
        .find(|item| item.ticket.title == "Write the outline")
        .unwrap()
        .ticket
        .id
        .clone();
    let unplanned_id = tasks
        .iter()
        .find(|item| item.ticket.title == "Maybe learn the harp")
        .unwrap()
        .ticket
        .id
        .clone();
    let writing_tag = tasks
        .iter()
        .find(|item| item.ticket.id == done_id)
        .unwrap()
        .tags
        .iter()
        .find(|tag| tag.name == "writing")
        .unwrap()
        .id
        .clone();
    assert!(db.visible_tickets(None).await.unwrap().is_empty());

    let req = aw_test::TestRequest::get()
        .uri(&format!("/?tag={writing_tag}"))
        .cookie(cookie.clone())
        .to_request();
    let body = body_string(aw_test::call_service(&app, req).await).await;
    assert!(body.contains("Write the outline"));
    assert!(!body.contains("Maybe learn the harp"));

    let req = aw_test::TestRequest::post()
        .uri(&format!("/tickets/{done_id}/close"))
        .cookie(cookie.clone())
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("outcome=done")
        .to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::SEE_OTHER
    );
    let req = aw_test::TestRequest::post()
        .uri(&format!("/tickets/{unplanned_id}/close"))
        .cookie(cookie.clone())
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("outcome=not_planned")
        .to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::SEE_OTHER
    );
    assert_eq!(db.done_ticket_count(&user.id).await.unwrap(), 1);
    let req = aw_test::TestRequest::get()
        .uri("/?view=closed")
        .cookie(cookie.clone())
        .to_request();
    let body = body_string(aw_test::call_service(&app, req).await).await;
    assert!(body.contains("Write the outline") && body.contains("Maybe learn the harp"));
    assert!(body.contains("Not planned"));
    assert!(body.contains("done"));
    assert!(!body.contains("XP") && !body.contains("Level 1"));

    // A closed ticket cannot be counted again unless it is reopened.
    assert!(!db.close_ticket(&done_id, &user.id, "done").await.unwrap());
    assert_eq!(db.done_ticket_count(&user.id).await.unwrap(), 1);
    assert!(db.reopen_ticket(&unplanned_id, &user.id).await.unwrap());
    assert!(
        db.close_ticket(&unplanned_id, &user.id, "done")
            .await
            .unwrap()
    );
    assert_eq!(db.done_ticket_count(&user.id).await.unwrap(), 2);

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_signup_requires_a_valid_invite() {
    let (db, mut config, db_path) = setup().await;
    config.invite_secret = None;
    let app = test_app!(db.clone(), config);

    let req = aw_test::TestRequest::get().uri("/auth/login").to_request();
    let body = body_string(aw_test::call_service(&app, req).await).await;
    assert!(!body.contains("/auth/invites"));

    let req = aw_test::TestRequest::get().uri("/auth/signup").to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::NOT_FOUND
    );

    let req = aw_test::TestRequest::post()
        .uri("/auth/signup")
        .set_form(Signup {
            username: "alice",
            email: "alice@example.com",
            password: "password123",
            invite: "not-a-valid-invite",
        })
        .to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::NOT_FOUND
    );

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_signup_rejects_invalid_invite() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db, config);

    let req = aw_test::TestRequest::post()
        .uri("/auth/signup")
        .set_form(Signup {
            username: "alice",
            email: "alice@example.com",
            password: "password123",
            invite: "wrong-token",
        })
        .to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::NOT_FOUND
    );

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_invite_secret_creates_a_one_time_signup_link() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db.clone(), config);

    let req = aw_test::TestRequest::post()
        .uri("/auth/invites")
        .insert_header(("HX-Request", "true"))
        .set_form(CreateInvite {
            invite_secret: "test-invite-secret",
        })
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert!(resp.status().is_success());
    let body = body_string(resp).await;
    let link_start =
        body.find("/auth/signup?invite=").expect("invite link") + "/auth/signup?invite=".len();
    let invite = &body[link_start..body[link_start..].find('"').unwrap() + link_start];

    let req = aw_test::TestRequest::get()
        .uri(&format!("/auth/signup?invite={invite}"))
        .to_request();
    assert!(aw_test::call_service(&app, req).await.status().is_success());

    let signup = || {
        aw_test::TestRequest::post()
            .uri("/auth/signup")
            .insert_header(("HX-Request", "true"))
            .set_form(Signup {
                username: "invited-user",
                email: "invited@example.com",
                password: "password123",
                invite,
            })
            .to_request()
    };
    let resp = aw_test::call_service(&app, signup()).await;
    assert!(resp.status().is_success());
    assert!(body_string(resp).await.contains("Account created"));
    assert_eq!(
        aw_test::call_service(&app, signup()).await.status(),
        StatusCode::NOT_FOUND
    );

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_private_ticket_is_hidden_from_others() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db.clone(), config);

    // Two users.
    for (index, (username, email)) in [("alice", "alice@example.com"), ("bob", "bob@example.com")]
        .into_iter()
        .enumerate()
    {
        let invite = format!("invite-{index}");
        db.create_invite(&invite).await.unwrap();
        let req = aw_test::TestRequest::post()
            .uri("/auth/signup")
            .insert_header(("HX-Request", "true"))
            .set_form(Signup {
                username,
                email,
                password: "password123",
                invite: &invite,
            })
            .to_request();
        assert!(aw_test::call_service(&app, req).await.status().is_success());
    }

    let login = |username: &'static str| async {
        let req = aw_test::TestRequest::post()
            .uri("/auth/login")
            .insert_header(("HX-Request", "true"))
            .set_form(Login {
                username,
                password: "password123",
            })
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        Cookie::new(
            SESSION_COOKIE_NAME,
            session_token(&resp).expect("session cookie"),
        )
    };

    let alice = login("alice").await;
    let bob = login("bob").await;

    let req = aw_test::TestRequest::post()
        .uri("/tickets")
        .cookie(alice.clone())
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("title=Alice%27s+private+note")
        .to_request();
    assert_eq!(
        aw_test::call_service(&app, req).await.status(),
        StatusCode::SEE_OTHER
    );

    // A different logged-in user and an anonymous visitor never receive it.
    for cookie in [Some(bob), None] {
        let mut req = aw_test::TestRequest::get().uri("/");
        if let Some(cookie) = cookie {
            req = req.cookie(cookie);
        }
        let body = body_string(aw_test::call_service(&app, req.to_request()).await).await;
        assert!(!body.contains("private note"));
    }
    assert_eq!(db.all_done_ticket_count().await.unwrap(), 0);

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_login_rejects_bad_credentials() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db.clone(), config);

    let req = aw_test::TestRequest::post()
        .uri("/auth/login")
        .insert_header(("HX-Request", "true"))
        .set_form(Login {
            username: "nobody",
            password: "password123",
        })
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
    assert!(session_token(&resp).is_none());

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_ticket_log_requires_login() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db.clone(), config);

    let req = aw_test::TestRequest::post()
        .uri("/tickets")
        .insert_header(("Content-Type", "application/x-www-form-urlencoded"))
        .set_payload("title=Nope")
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::SEE_OTHER);
    assert_eq!(resp.headers().get("Location").unwrap(), "/");

    let req = aw_test::TestRequest::get()
        .uri("/tickets")
        .insert_header(("HX-Request", "true"))
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::SEE_OTHER);

    cleanup(&db_path);
}

#[actix_web::test]
async fn test_logout_clears_session() {
    let (db, config, db_path) = setup().await;
    let app = test_app!(db.clone(), config);

    let req = aw_test::TestRequest::post()
        .uri("/auth/signup")
        .insert_header(("HX-Request", "true"))
        .set_form(Signup {
            username: "alice",
            email: "alice@example.com",
            password: "password123",
            invite: "test-invite",
        })
        .to_request();
    assert!(aw_test::call_service(&app, req).await.status().is_success());

    let req = aw_test::TestRequest::post()
        .uri("/auth/login")
        .insert_header(("HX-Request", "true"))
        .set_form(Login {
            username: "alice",
            password: "password123",
        })
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    let token = session_token(&resp).expect("session cookie");
    let cookie = Cookie::new(SESSION_COOKIE_NAME, token.clone());

    let req = aw_test::TestRequest::post()
        .uri("/auth/logout")
        .cookie(cookie)
        .insert_header(("HX-Request", "true"))
        .to_request();
    let resp = aw_test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::OK);

    let mut rows = db
        .conn()
        .await
        .unwrap()
        .query("SELECT COUNT(*) FROM sessions", ())
        .await
        .unwrap();
    let count = rows.next().await.unwrap().unwrap().get::<i64>(0).unwrap();
    assert_eq!(count, 0, "logout must delete the server-side session");

    cleanup(&db_path);
}