You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1438 lines
46 KiB
Rust
1438 lines
46 KiB
Rust
//! Integration tests for the sources CRUD endpoints (Phase 2).
|
|
//!
|
|
//! Tests:
|
|
//! - GET /api/v1/sources — list user's sources
|
|
//! - POST /api/v1/sources — create a single source
|
|
//! - DELETE /api/v1/sources/:id — delete a source
|
|
//! - POST /api/v1/sources/bulk — bulk import from JSON array
|
|
//! - POST /api/v1/sources/import-csv — import from CSV file upload
|
|
//! - GET /api/v1/sources/export-csv — download sources as CSV
|
|
//!
|
|
//! Covers authentication, validation, ownership isolation, max limit,
|
|
//! duplicate handling, and CSV roundtrip.
|
|
//!
|
|
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
|
|
|
|
mod common;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Method, Request, StatusCode};
|
|
|
|
fn require_test_db() -> bool {
|
|
std::env::var("TEST_DATABASE_URL").is_ok()
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Authentication
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn get_sources_without_auth_returns_401() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (status, body) = app.get_with_session("/api/v1/sources", "invalid-session-token").await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNAUTHORIZED,
|
|
"GET /sources without auth should return 401"
|
|
);
|
|
assert_eq!(body["error"], "unauthorized");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_sources_without_auth_returns_401() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let body = serde_json::json!({
|
|
"title": "My Blog",
|
|
"url": "https://example.com"
|
|
});
|
|
let (status, resp) = app.post("/api/v1/sources", &body).await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNAUTHORIZED,
|
|
"POST /sources without auth should return 401"
|
|
);
|
|
assert_eq!(resp["error"], "unauthorized");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_source_without_auth_returns_401() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let fake_id = uuid::Uuid::new_v4();
|
|
|
|
// Build a DELETE request without session
|
|
let req = Request::builder()
|
|
.method(Method::DELETE)
|
|
.uri(&format!("/api/v1/sources/{}", fake_id))
|
|
.header("X-Requested-With", "XMLHttpRequest")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.raw_request(req).await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"DELETE /sources/:id without auth should return 401"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CRUD — Basic operations
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn get_sources_empty_list() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-empty@example.com")
|
|
.await;
|
|
|
|
let (status, body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
|
|
assert_eq!(status, StatusCode::OK, "GET /sources should return 200");
|
|
let sources = body.as_array().expect("Response should be an array");
|
|
assert!(sources.is_empty(), "New user should have no sources");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_with_valid_data_returns_201() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-create@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"title": "My Blog",
|
|
"url": "https://blog.example.com"
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::CREATED,
|
|
"POST /sources with valid data should return 201"
|
|
);
|
|
assert_eq!(resp["title"], "My Blog");
|
|
assert_eq!(resp["url"], "https://blog.example.com");
|
|
assert!(
|
|
resp["id"].as_str().is_some(),
|
|
"Response should contain an id"
|
|
);
|
|
assert!(
|
|
resp["created_at"].as_str().is_some(),
|
|
"Response should contain created_at"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_then_list_shows_it() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-create-list@example.com")
|
|
.await;
|
|
|
|
// Create a source
|
|
let body = serde_json::json!({
|
|
"title": "Tech News",
|
|
"url": "https://technews.example.com"
|
|
});
|
|
let (create_status, create_resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(create_status, StatusCode::CREATED);
|
|
let created_id = create_resp["id"].as_str().unwrap();
|
|
|
|
// List sources
|
|
let (list_status, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
assert_eq!(list_status, StatusCode::OK);
|
|
|
|
let sources = list_body.as_array().expect("Should be an array");
|
|
assert_eq!(sources.len(), 1, "Should have exactly one source");
|
|
assert_eq!(sources[0]["id"], created_id);
|
|
assert_eq!(sources[0]["title"], "Tech News");
|
|
assert_eq!(sources[0]["url"], "https://technews.example.com");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_multiple_sources_list_returns_all() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-multi@example.com")
|
|
.await;
|
|
|
|
// Create three sources
|
|
for i in 1..=3 {
|
|
let body = serde_json::json!({
|
|
"title": format!("Source {}", i),
|
|
"url": format!("https://source{}.example.com", i)
|
|
});
|
|
let (status, _) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
}
|
|
|
|
// List should have 3 sources
|
|
let (status, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 3, "Should have exactly 3 sources");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CRUD — Validation errors
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn create_source_invalid_url_returns_422() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-invalid-url@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"title": "My Blog",
|
|
"url": "not-a-valid-url"
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Invalid URL should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_ftp_url_returns_422() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-ftp-url@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"title": "FTP Source",
|
|
"url": "ftp://files.example.com"
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"FTP URL should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_empty_title_returns_422() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-empty-title@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"title": " ",
|
|
"url": "https://example.com"
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Empty title should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_title_too_long_returns_422() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-title-long@example.com")
|
|
.await;
|
|
|
|
let long_title = "a".repeat(201);
|
|
let body = serde_json::json!({
|
|
"title": long_title,
|
|
"url": "https://example.com"
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Title >200 chars should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_url_too_long_returns_422() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-url-long@example.com")
|
|
.await;
|
|
|
|
let long_url = format!("https://example.com/{}", "a".repeat(990));
|
|
assert!(long_url.len() > 1000);
|
|
let body = serde_json::json!({
|
|
"title": "My Blog",
|
|
"url": long_url
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"URL >1000 chars should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_empty_url_returns_422() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-empty-url@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"title": "My Blog",
|
|
"url": ""
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Empty URL should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CRUD — Duplicate URL handling
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn create_source_duplicate_url_returns_error() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-dup@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"title": "My Blog",
|
|
"url": "https://duplicate.example.com"
|
|
});
|
|
|
|
// First creation should succeed
|
|
let (status1, _) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(status1, StatusCode::CREATED);
|
|
|
|
// Second creation with same URL should fail (DB unique constraint)
|
|
let body2 = serde_json::json!({
|
|
"title": "Different Title",
|
|
"url": "https://duplicate.example.com"
|
|
});
|
|
let (status2, _) = app
|
|
.post_with_session("/api/v1/sources", &body2, &session)
|
|
.await;
|
|
|
|
// The DB has a unique constraint on (user_id, url), so this should return
|
|
// an error (500 from DB constraint or could be handled as 409 Conflict).
|
|
// Since the handler doesn't explicitly check for duplicates before insert,
|
|
// it will hit the DB constraint, which maps to an internal error.
|
|
assert!(
|
|
status2 == StatusCode::INTERNAL_SERVER_ERROR
|
|
|| status2 == StatusCode::CONFLICT
|
|
|| status2 == StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Duplicate URL should return an error status, got {}",
|
|
status2
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CRUD — Delete
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn delete_source_valid_id_returns_204() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-delete@example.com")
|
|
.await;
|
|
|
|
// Create a source
|
|
let body = serde_json::json!({
|
|
"title": "To Delete",
|
|
"url": "https://delete.example.com"
|
|
});
|
|
let (create_status, create_resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(create_status, StatusCode::CREATED);
|
|
let source_id = create_resp["id"].as_str().unwrap();
|
|
|
|
// Delete it
|
|
let (del_status, _) = app
|
|
.delete_with_session(&format!("/api/v1/sources/{}", source_id), &session)
|
|
.await;
|
|
assert_eq!(
|
|
del_status,
|
|
StatusCode::NO_CONTENT,
|
|
"DELETE should return 204"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_source_then_list_no_longer_shows_it() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-delete-list@example.com")
|
|
.await;
|
|
|
|
// Create two sources
|
|
let body1 = serde_json::json!({
|
|
"title": "Keep This",
|
|
"url": "https://keep.example.com"
|
|
});
|
|
let (_, resp1) = app
|
|
.post_with_session("/api/v1/sources", &body1, &session)
|
|
.await;
|
|
let keep_id = resp1["id"].as_str().unwrap().to_string();
|
|
|
|
let body2 = serde_json::json!({
|
|
"title": "Delete This",
|
|
"url": "https://delete-me.example.com"
|
|
});
|
|
let (_, resp2) = app
|
|
.post_with_session("/api/v1/sources", &body2, &session)
|
|
.await;
|
|
let delete_id = resp2["id"].as_str().unwrap();
|
|
|
|
// Delete the second one
|
|
let (del_status, _) = app
|
|
.delete_with_session(&format!("/api/v1/sources/{}", delete_id), &session)
|
|
.await;
|
|
assert_eq!(del_status, StatusCode::NO_CONTENT);
|
|
|
|
// List should only show the first source
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 1, "Should have exactly 1 source after delete");
|
|
assert_eq!(sources[0]["id"], keep_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_source_nonexistent_id_returns_404() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-delete-404@example.com")
|
|
.await;
|
|
|
|
let fake_id = uuid::Uuid::new_v4();
|
|
let (status, body) = app
|
|
.delete_with_session(&format!("/api/v1/sources/{}", fake_id), &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::NOT_FOUND,
|
|
"DELETE with non-existent id should return 404"
|
|
);
|
|
assert_eq!(body["error"], "not_found");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Ownership Isolation
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn user_a_sources_not_visible_to_user_b() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
|
|
let (_user_a_id, session_a) = app
|
|
.create_authenticated_user("user-a-sources@example.com")
|
|
.await;
|
|
let (_user_b_id, session_b) = app
|
|
.create_authenticated_user("user-b-sources@example.com")
|
|
.await;
|
|
|
|
// User A creates a source
|
|
let body = serde_json::json!({
|
|
"title": "User A Blog",
|
|
"url": "https://usera.example.com"
|
|
});
|
|
let (status, _) = app
|
|
.post_with_session("/api/v1/sources", &body, &session_a)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
|
// User B lists sources -> should be empty
|
|
let (list_status, list_body) = app
|
|
.get_with_session("/api/v1/sources", &session_b)
|
|
.await;
|
|
assert_eq!(list_status, StatusCode::OK);
|
|
let sources = list_body.as_array().unwrap();
|
|
assert!(
|
|
sources.is_empty(),
|
|
"User B should NOT see User A's sources"
|
|
);
|
|
|
|
// User A lists sources -> should see their source
|
|
let (_, list_body_a) = app
|
|
.get_with_session("/api/v1/sources", &session_a)
|
|
.await;
|
|
let sources_a = list_body_a.as_array().unwrap();
|
|
assert_eq!(sources_a.len(), 1, "User A should see their own source");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_b_cannot_delete_user_a_source_returns_404() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
|
|
let (_user_a_id, session_a) = app
|
|
.create_authenticated_user("owner-a@example.com")
|
|
.await;
|
|
let (_user_b_id, session_b) = app
|
|
.create_authenticated_user("attacker-b@example.com")
|
|
.await;
|
|
|
|
// User A creates a source
|
|
let body = serde_json::json!({
|
|
"title": "User A Private",
|
|
"url": "https://private-a.example.com"
|
|
});
|
|
let (_, create_resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session_a)
|
|
.await;
|
|
let source_id = create_resp["id"].as_str().unwrap();
|
|
|
|
// User B tries to delete it -> should get 404 (NOT 403, to avoid info leakage)
|
|
let (del_status, del_body) = app
|
|
.delete_with_session(&format!("/api/v1/sources/{}", source_id), &session_b)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
del_status,
|
|
StatusCode::NOT_FOUND,
|
|
"Deleting another user's source should return 404, not 403"
|
|
);
|
|
assert_eq!(del_body["error"], "not_found");
|
|
|
|
// Verify User A's source is still there
|
|
let (_, list_body) = app
|
|
.get_with_session("/api/v1/sources", &session_a)
|
|
.await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(
|
|
sources.len(),
|
|
1,
|
|
"User A's source should NOT have been deleted"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Bulk Import
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn bulk_import_valid_sources_succeeds() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("bulk-import@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"sources": [
|
|
{ "title": "Blog 1", "url": "https://blog1.example.com" },
|
|
{ "title": "Blog 2", "url": "https://blog2.example.com" },
|
|
{ "title": "Blog 3", "url": "https://blog3.example.com" }
|
|
]
|
|
});
|
|
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources/bulk", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::OK,
|
|
"Bulk import should return 200"
|
|
);
|
|
assert_eq!(resp["imported"], 3, "Should have imported 3 sources");
|
|
assert_eq!(resp["skipped"], 0, "Should have skipped 0 sources");
|
|
|
|
// Verify they appear in the list
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_import_with_duplicates_skips_them() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("bulk-dup@example.com")
|
|
.await;
|
|
|
|
// First, create one source normally
|
|
let body = serde_json::json!({
|
|
"title": "Existing",
|
|
"url": "https://existing.example.com"
|
|
});
|
|
let (status, _) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
|
// Now bulk import with the same URL + a new one
|
|
let bulk = serde_json::json!({
|
|
"sources": [
|
|
{ "title": "Existing Dup", "url": "https://existing.example.com" },
|
|
{ "title": "New One", "url": "https://new.example.com" }
|
|
]
|
|
});
|
|
|
|
let (bulk_status, resp) = app
|
|
.post_with_session("/api/v1/sources/bulk", &bulk, &session)
|
|
.await;
|
|
|
|
assert_eq!(bulk_status, StatusCode::OK);
|
|
assert_eq!(
|
|
resp["imported"], 1,
|
|
"Only 1 new source should be imported"
|
|
);
|
|
assert_eq!(
|
|
resp["skipped"], 1,
|
|
"1 duplicate should be skipped"
|
|
);
|
|
|
|
// Total should be 2 (original + new)
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_import_empty_array_returns_error() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("bulk-empty@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"sources": []
|
|
});
|
|
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources/bulk", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Empty bulk import should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_import_with_invalid_entries_reports_errors() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("bulk-invalid@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"sources": [
|
|
{ "title": "Valid", "url": "https://valid.example.com" },
|
|
{ "title": "", "url": "https://empty-title.example.com" },
|
|
{ "title": "Bad URL", "url": "not-a-url" },
|
|
{ "title": "Also Valid", "url": "https://alsovalid.example.com" }
|
|
]
|
|
});
|
|
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources/bulk", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(status, StatusCode::OK, "Bulk import should still return 200");
|
|
assert_eq!(
|
|
resp["imported"], 2,
|
|
"Only the 2 valid sources should be imported"
|
|
);
|
|
let errors = resp["errors"].as_array().expect("errors should be an array");
|
|
assert_eq!(
|
|
errors.len(),
|
|
2,
|
|
"Should have 2 validation errors (empty title + invalid URL)"
|
|
);
|
|
|
|
// Verify only 2 sources exist
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_import_without_auth_returns_401() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let body = serde_json::json!({
|
|
"sources": [
|
|
{ "title": "Blog", "url": "https://blog.example.com" }
|
|
]
|
|
});
|
|
|
|
let (status, resp) = app.post("/api/v1/sources/bulk", &body).await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNAUTHORIZED,
|
|
"Bulk import without auth should return 401"
|
|
);
|
|
assert_eq!(resp["error"], "unauthorized");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CSV Export
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn export_csv_with_sources_returns_csv() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("csv-export@example.com")
|
|
.await;
|
|
|
|
// Create some sources
|
|
for i in 1..=2 {
|
|
let body = serde_json::json!({
|
|
"title": format!("Source {}", i),
|
|
"url": format!("https://source{}.example.com", i)
|
|
});
|
|
let (s, _) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(s, StatusCode::CREATED);
|
|
}
|
|
|
|
// Export CSV
|
|
let req = Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/api/v1/sources/export-csv")
|
|
.header(
|
|
"Cookie",
|
|
format!("ai_synth_session={}", session),
|
|
)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let (status, text, headers) = app.raw_request_text(req).await;
|
|
|
|
assert_eq!(status, StatusCode::OK, "CSV export should return 200");
|
|
|
|
// Check Content-Type
|
|
let content_type = headers
|
|
.get("content-type")
|
|
.map(|v| v.to_str().unwrap_or(""))
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("text/csv"),
|
|
"Content-Type should be text/csv, got: {}",
|
|
content_type
|
|
);
|
|
|
|
// Check Content-Disposition
|
|
let disposition = headers
|
|
.get("content-disposition")
|
|
.map(|v| v.to_str().unwrap_or(""))
|
|
.unwrap_or("");
|
|
assert!(
|
|
disposition.contains("attachment"),
|
|
"Content-Disposition should indicate attachment, got: {}",
|
|
disposition
|
|
);
|
|
|
|
// Check CSV content
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
assert!(lines.len() >= 3, "CSV should have header + 2 data rows");
|
|
assert_eq!(lines[0], "title,url", "First line should be the header");
|
|
// Data rows — order is newest-first
|
|
assert!(
|
|
text.contains("Source 1") && text.contains("Source 2"),
|
|
"CSV should contain both sources"
|
|
);
|
|
assert!(
|
|
text.contains("https://source1.example.com")
|
|
&& text.contains("https://source2.example.com"),
|
|
"CSV should contain both URLs"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn export_csv_with_no_sources_returns_header_only() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("csv-export-empty@example.com")
|
|
.await;
|
|
|
|
let req = Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/api/v1/sources/export-csv")
|
|
.header(
|
|
"Cookie",
|
|
format!("ai_synth_session={}", session),
|
|
)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let (status, text, _) = app.raw_request_text(req).await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
assert_eq!(lines.len(), 1, "Should have only the header row");
|
|
assert_eq!(lines[0], "title,url");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn export_csv_without_auth_returns_401() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
|
|
let req = Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/api/v1/sources/export-csv")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.raw_request(req).await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"CSV export without auth should return 401"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CSV Import
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Helper to build a multipart request body for CSV import.
|
|
fn build_csv_multipart_request(
|
|
csv_content: &str,
|
|
session_cookie: &str,
|
|
) -> Request<Body> {
|
|
let boundary = "----TestBoundary12345";
|
|
let body = format!(
|
|
"--{boundary}\r\n\
|
|
Content-Disposition: form-data; name=\"file\"; filename=\"sources.csv\"\r\n\
|
|
Content-Type: text/csv\r\n\
|
|
\r\n\
|
|
{csv_content}\r\n\
|
|
--{boundary}--\r\n",
|
|
boundary = boundary,
|
|
csv_content = csv_content
|
|
);
|
|
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/api/v1/sources/import-csv")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={}", boundary),
|
|
)
|
|
.header("X-Requested-With", "XMLHttpRequest")
|
|
.header(
|
|
"Cookie",
|
|
format!("ai_synth_session={}", session_cookie),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_csv_with_valid_data_succeeds() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("csv-import@example.com")
|
|
.await;
|
|
|
|
let csv_content = "title,url\nBlog One,https://blog1.example.com\nBlog Two,https://blog2.example.com";
|
|
let req = build_csv_multipart_request(csv_content, &session);
|
|
|
|
let response = app.raw_request(req).await;
|
|
let status = response.status();
|
|
|
|
assert_eq!(status, StatusCode::OK, "CSV import should return 200");
|
|
|
|
// Parse the response body
|
|
let bytes = http_body_util::BodyExt::collect(response.into_body())
|
|
.await
|
|
.unwrap()
|
|
.to_bytes();
|
|
let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
|
|
assert_eq!(resp["imported"], 2, "Should have imported 2 sources");
|
|
assert_eq!(resp["skipped"], 0);
|
|
|
|
// Verify via list
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_csv_semicolon_separated_succeeds() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("csv-semicolon@example.com")
|
|
.await;
|
|
|
|
let csv_content = "titre;lien\nMon Blog;https://monblog.example.com\nActus;https://actus.example.com";
|
|
let req = build_csv_multipart_request(csv_content, &session);
|
|
|
|
let response = app.raw_request(req).await;
|
|
let status = response.status();
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::OK,
|
|
"CSV import with semicolons should return 200"
|
|
);
|
|
|
|
let bytes = http_body_util::BodyExt::collect(response.into_body())
|
|
.await
|
|
.unwrap()
|
|
.to_bytes();
|
|
let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
|
|
assert_eq!(
|
|
resp["imported"], 2,
|
|
"Should import 2 sources from semicolon-separated CSV"
|
|
);
|
|
|
|
// Verify the actual titles
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 2);
|
|
let titles: Vec<&str> = sources
|
|
.iter()
|
|
.map(|s| s["title"].as_str().unwrap())
|
|
.collect();
|
|
assert!(titles.contains(&"Mon Blog"));
|
|
assert!(titles.contains(&"Actus"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_csv_without_auth_returns_401() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
|
|
let boundary = "----TestBoundary12345";
|
|
let body_str = format!(
|
|
"--{boundary}\r\n\
|
|
Content-Disposition: form-data; name=\"file\"; filename=\"sources.csv\"\r\n\
|
|
Content-Type: text/csv\r\n\
|
|
\r\n\
|
|
title,url\nBlog,https://example.com\r\n\
|
|
--{boundary}--\r\n",
|
|
boundary = boundary
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/api/v1/sources/import-csv")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={}", boundary),
|
|
)
|
|
.header("X-Requested-With", "XMLHttpRequest")
|
|
.body(Body::from(body_str))
|
|
.unwrap();
|
|
|
|
let response = app.raw_request(req).await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"CSV import without auth should return 401"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CSV Roundtrip: create sources -> export -> verify content
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn csv_export_roundtrip() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("csv-roundtrip@example.com")
|
|
.await;
|
|
|
|
// Create sources
|
|
let source_data = vec![
|
|
("Rust Blog", "https://rust-blog.example.com"),
|
|
("AI News", "https://ainews.example.com"),
|
|
("Tech Crunch", "https://techcrunch.example.com"),
|
|
];
|
|
|
|
for (title, url) in &source_data {
|
|
let body = serde_json::json!({ "title": title, "url": url });
|
|
let (s, _) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(s, StatusCode::CREATED);
|
|
}
|
|
|
|
// Export CSV
|
|
let req = Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/api/v1/sources/export-csv")
|
|
.header("Cookie", format!("ai_synth_session={}", session))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let (status, csv_text, _) = app.raw_request_text(req).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
// Verify CSV has a header and 3 data rows
|
|
let lines: Vec<&str> = csv_text.lines().collect();
|
|
assert_eq!(lines[0], "title,url");
|
|
// There should be 3 data rows (newest first, but we don't care about order here)
|
|
assert_eq!(
|
|
lines.len() - 1, // subtract header
|
|
3,
|
|
"Should have 3 data rows"
|
|
);
|
|
|
|
// Verify all sources are present
|
|
for (title, url) in &source_data {
|
|
assert!(
|
|
csv_text.contains(title),
|
|
"CSV should contain title '{}'",
|
|
title
|
|
);
|
|
assert!(
|
|
csv_text.contains(url),
|
|
"CSV should contain URL '{}'",
|
|
url
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Max Sources Limit
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn max_sources_limit_enforced() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (user_id, session) = app
|
|
.create_authenticated_user("max-sources@example.com")
|
|
.await;
|
|
|
|
// Insert 100 sources directly into the database (faster than 100 API calls)
|
|
for i in 0..100 {
|
|
sqlx::query(
|
|
"INSERT INTO sources (user_id, title, url) VALUES ($1, $2, $3)",
|
|
)
|
|
.bind(user_id)
|
|
.bind(format!("Source {}", i))
|
|
.bind(format!("https://source{}.example.com", i))
|
|
.execute(&app.pool)
|
|
.await
|
|
.expect("Failed to insert source");
|
|
}
|
|
|
|
// Verify we have 100
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 100, "Should have 100 sources");
|
|
|
|
// Attempt to create the 101st source via API
|
|
let body = serde_json::json!({
|
|
"title": "One Too Many",
|
|
"url": "https://toomany.example.com"
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Creating more than 100 sources should return 422"
|
|
);
|
|
assert_eq!(resp["error"], "validation_error");
|
|
assert!(
|
|
resp["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("100"),
|
|
"Error message should mention the limit"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_import_respects_max_sources_limit() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (user_id, session) = app
|
|
.create_authenticated_user("bulk-max@example.com")
|
|
.await;
|
|
|
|
// Insert 98 sources directly
|
|
for i in 0..98 {
|
|
sqlx::query(
|
|
"INSERT INTO sources (user_id, title, url) VALUES ($1, $2, $3)",
|
|
)
|
|
.bind(user_id)
|
|
.bind(format!("Source {}", i))
|
|
.bind(format!("https://source{}.example.com", i))
|
|
.execute(&app.pool)
|
|
.await
|
|
.expect("Failed to insert source");
|
|
}
|
|
|
|
// Bulk import 5 sources (but only 2 slots remaining)
|
|
let sources: Vec<serde_json::Value> = (0..5)
|
|
.map(|i| {
|
|
serde_json::json!({
|
|
"title": format!("Bulk {}", i),
|
|
"url": format!("https://bulk{}.example.com", i)
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let body = serde_json::json!({ "sources": sources });
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources/bulk", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(
|
|
resp["imported"], 2,
|
|
"Only 2 should be imported (remaining capacity)"
|
|
);
|
|
|
|
let errors = resp["errors"].as_array().unwrap();
|
|
assert!(
|
|
!errors.is_empty(),
|
|
"Should report that limit was reached"
|
|
);
|
|
|
|
// Verify total is exactly 100
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let all_sources = list_body.as_array().unwrap();
|
|
assert_eq!(all_sources.len(), 100, "Should have exactly 100 sources");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Boundary / Edge Cases
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test]
|
|
async fn create_source_with_boundary_values_succeeds() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-boundary@example.com")
|
|
.await;
|
|
|
|
// Title exactly 200 chars
|
|
let title_200 = "a".repeat(200);
|
|
let body = serde_json::json!({
|
|
"title": title_200,
|
|
"url": "https://boundary-title.example.com"
|
|
});
|
|
let (status, _) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::CREATED,
|
|
"Title of exactly 200 chars should be accepted"
|
|
);
|
|
|
|
// URL exactly 1000 chars
|
|
let url_1000 = format!("https://example.com/{}", "b".repeat(980));
|
|
assert_eq!(url_1000.len(), 1000);
|
|
let body2 = serde_json::json!({
|
|
"title": "Boundary URL",
|
|
"url": url_1000
|
|
});
|
|
let (status2, _) = app
|
|
.post_with_session("/api/v1/sources", &body2, &session)
|
|
.await;
|
|
assert_eq!(
|
|
status2,
|
|
StatusCode::CREATED,
|
|
"URL of exactly 1000 chars should be accepted"
|
|
);
|
|
|
|
// Minimal valid source
|
|
let body3 = serde_json::json!({
|
|
"title": "A",
|
|
"url": "http://x.co"
|
|
});
|
|
let (status3, _) = app
|
|
.post_with_session("/api/v1/sources", &body3, &session)
|
|
.await;
|
|
assert_eq!(
|
|
status3,
|
|
StatusCode::CREATED,
|
|
"Minimal valid source should be accepted"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_source_with_http_url_succeeds() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("sources-http@example.com")
|
|
.await;
|
|
|
|
let body = serde_json::json!({
|
|
"title": "HTTP Source",
|
|
"url": "http://insecure.example.com"
|
|
});
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::CREATED,
|
|
"http:// URLs should be accepted"
|
|
);
|
|
assert_eq!(resp["url"], "http://insecure.example.com");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_import_all_duplicates_within_batch() {
|
|
if !require_test_db() {
|
|
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
|
return;
|
|
}
|
|
|
|
let app = common::TestApp::new().await;
|
|
let (_user_id, session) = app
|
|
.create_authenticated_user("bulk-inner-dup@example.com")
|
|
.await;
|
|
|
|
// Same URL appearing twice in one batch
|
|
let body = serde_json::json!({
|
|
"sources": [
|
|
{ "title": "First", "url": "https://same.example.com" },
|
|
{ "title": "Second", "url": "https://same.example.com" }
|
|
]
|
|
});
|
|
|
|
let (status, resp) = app
|
|
.post_with_session("/api/v1/sources/bulk", &body, &session)
|
|
.await;
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
// The ON CONFLICT DO NOTHING means the second insert is a no-op
|
|
assert_eq!(resp["imported"], 1, "Only one should be imported");
|
|
assert_eq!(resp["skipped"], 1, "The duplicate should be counted as skipped");
|
|
|
|
// Verify only 1 source exists
|
|
let (_, list_body) = app.get_with_session("/api/v1/sources", &session).await;
|
|
let sources = list_body.as_array().unwrap();
|
|
assert_eq!(sources.len(), 1);
|
|
}
|