···11+# =============================================================================
22+# Server
33+# =============================================================================
14SERVER_HOST=127.0.0.1
25SERVER_PORT=3000
3677+# The public-facing hostname of the PDS (used in DID documents, JWTs, etc.)
88+PDS_HOSTNAME=localhost:3000
99+1010+# =============================================================================
1111+# Database
1212+# =============================================================================
413DATABASE_URL=postgres://postgres:postgres@localhost:5432/pds
51466-S3_ENDPOINT=http://objsto:9000
1515+# Connection pool settings (defaults are good for most deployments)
1616+# DATABASE_MAX_CONNECTIONS=100
1717+# DATABASE_MIN_CONNECTIONS=10
1818+# DATABASE_ACQUIRE_TIMEOUT_SECS=30
1919+2020+# =============================================================================
2121+# Blob Storage (S3-compatible)
2222+# =============================================================================
2323+S3_ENDPOINT=http://localhost:9000
724AWS_REGION=us-east-1
825S3_BUCKET=pds-blobs
926AWS_ACCESS_KEY_ID=minioadmin
1027AWS_SECRET_ACCESS_KEY=minioadmin
11281212-# The public-facing hostname of the PDS
1313-PDS_HOSTNAME=localhost:3000
1414-PLC_URL=plc.directory
2929+# =============================================================================
3030+# Valkey (for caching and distributed rate limiting)
3131+# =============================================================================
3232+# If not set, falls back to in-memory caching (single-node only)
3333+# VALKEY_URL=redis://localhost:6379
3434+3535+# =============================================================================
3636+# Security Secrets
3737+# =============================================================================
3838+# These MUST be set in production (minimum 32 characters each)
3939+# In development, set BSPDS_ALLOW_INSECURE_SECRETS=1 to use defaults
4040+4141+# Server-wide secret for OAuth token signing (HS256)
4242+# JWT_SECRET=your-secure-random-string-at-least-32-chars
15431616-# A comma-separated list of relay URLs to notify via requestCrawl when we have updates.
1717-# e.g., CRAWLERS=https://bsky.network
1818-CRAWLERS=
4444+# Secret for DPoP proof validation
4545+# DPOP_SECRET=your-secure-random-string-at-least-32-chars
19462020-# Notification Service Configuration
2121-# At least one notification channel should be configured for user notifications to work.
4747+# Key for encrypting user signing keys at rest (AES-256-GCM)
4848+# MASTER_KEY=your-secure-random-string-at-least-32-chars
4949+5050+# Set this ONLY in development to allow default/weak secrets
5151+# BSPDS_ALLOW_INSECURE_SECRETS=1
5252+5353+# =============================================================================
5454+# PLC Directory
5555+# =============================================================================
5656+# PLC_DIRECTORY_URL=https://plc.directory
5757+# PLC_TIMEOUT_SECS=10
5858+# PLC_CONNECT_TIMEOUT_SECS=5
5959+6060+# Optional: rotation key for PLC operations (defaults to user's key)
6161+# PLC_ROTATION_KEY=did:key:...
6262+6363+# =============================================================================
6464+# Federation
6565+# =============================================================================
6666+# Appview URL for proxying app.bsky.* requests
6767+# APPVIEW_URL=https://api.bsky.app
6868+6969+# Comma-separated list of relay URLs to notify via requestCrawl
7070+# CRAWLERS=https://bsky.network
7171+7272+# =============================================================================
7373+# Firehose (subscribeRepos WebSocket)
7474+# =============================================================================
7575+# Buffer size for firehose broadcast channel
7676+# FIREHOSE_BUFFER_SIZE=10000
7777+7878+# Disconnect slow consumers after this many events of lag
7979+# FIREHOSE_MAX_LAG=5000
8080+8181+# =============================================================================
8282+# Notification Service
8383+# =============================================================================
8484+# Queue processing settings
8585+# NOTIFICATION_BATCH_SIZE=100
8686+# NOTIFICATION_POLL_INTERVAL_MS=1000
22872388# Email notifications (via sendmail/msmtp)
2489# MAIL_FROM_ADDRESS=noreply@example.com
···3499# Signal notifications (via signal-cli)
35100# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
36101# SIGNAL_SENDER_NUMBER=+1234567890
102102+103103+# =============================================================================
104104+# Repository Import
105105+# =============================================================================
106106+# Set to "true" to accept repository imports
107107+# ACCEPTING_REPO_IMPORTS=false
108108+109109+# Maximum import size in bytes (default: 50MB)
110110+# MAX_IMPORT_SIZE=52428800
111111+112112+# Maximum blocks per import (default: 100000)
113113+# MAX_IMPORT_BLOCKS=100000
114114+115115+# Skip verification during import (testing only)
116116+# SKIP_IMPORT_VERIFICATION=false
117117+118118+# =============================================================================
119119+# Account Registration
120120+# =============================================================================
121121+# Require invite codes for registration
122122+# INVITE_CODE_REQUIRED=false
123123+124124+# Comma-separated list of available user domains
125125+# AVAILABLE_USER_DOMAINS=example.com
126126+127127+# =============================================================================
128128+# Rate Limiting
129129+# =============================================================================
130130+# Disable all rate limiting (testing only, NEVER in production)
131131+# DISABLE_RATE_LIMITING=1
132132+133133+# =============================================================================
134134+# Miscellaneous
135135+# =============================================================================
136136+# Allow HTTP for proxy requests (development only)
137137+# ALLOW_HTTP_PROXY=1
138138+139139+# Custom frontend directory (defaults to ./frontend/dist)
140140+# FRONTEND_DIR=/path/to/frontend/dist
3714138142CARGO_MOMMYS_LITTLE=mister
39143CARGO_MOMMYS_PRONOUNS=his
···11-# BSPDS, a Personal Data Server
22-33-A production-grade Personal Data Server (PDS) implementation for the AT Protocol.
11+# BSPDS
4255-Uses PostgreSQL instead of SQLite, S3-compatible blob storage, and is designed to be a complete drop-in replacement for Bluesky's reference PDS implementation.
33+A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, using postgres and s3-compatible blob storage.
6475## Features
8699-- Full AT Protocol support, all `com.atproto.*` endpoints implemented
1010-- OAuth 2.1 Provider. PKCE, DPoP, Pushed Authorization Requests
1111-- PostgreSQL, prod-ready database backend
1212-- S3-compatible object storage for blobs; works with AWS S3, UpCloud object storage, self-hosted MinIO, etc.
1313-- WebSocket `subscribeRepos` endpoint for real-time sync
1414-- Crawler notifications via `requestCrawl`
1515-- Multi-channel notifications: email, discord, telegram, signal
1616-- Per-IP rate limiting on sensitive endpoints
77+- Full AT Protocol support (`com.atproto.*` endpoints)
88+- OAuth 2.1 provider (PKCE, DPoP, PAR)
99+- WebSocket firehose (`subscribeRepos`)
1010+- Multi-channel notifications (email, discord, telegram, signal)
1711- Built-in web UI for account management
1818-1919-## Running Locally
2020-2121-Requires Rust installed locally.
2222-2323-Run PostgreSQL and S3-compatible object store (e.g., with podman/docker):
2424-2525-```bash
2626-podman compose up db objsto -d
2727-```
1212+- Per-IP rate limiting
28132929-Run the PDS:
1414+## Quick Start
30153116```bash
1717+cp .env.example .env
1818+podman compose up -d
3219just run
3320```
34213522## Configuration
36233737-### Required
3838-3939-| Variable | Description |
4040-|----------|-------------|
4141-| `DATABASE_URL` | PostgreSQL connection string |
4242-| `S3_BUCKET` | Blob storage bucket name |
4343-| `S3_ENDPOINT` | S3 endpoint URL (for MinIO, etc.) |
4444-| `AWS_ACCESS_KEY_ID` | S3 credentials |
4545-| `AWS_SECRET_ACCESS_KEY` | S3 credentials |
4646-| `AWS_REGION` | S3 region |
4747-| `PDS_HOSTNAME` | Public hostname of this PDS |
4848-| `JWT_SECRET` | Secret for OAuth token signing (HS256) |
4949-| `KEY_ENCRYPTION_KEY` | Key for encrypting user signing keys (AES-256-GCM) |
5050-5151-### Optional
5252-5353-| Variable | Description |
5454-|----------|-------------|
5555-| `APPVIEW_URL` | Appview URL to proxy unimplemented endpoints to |
5656-| `CRAWLERS` | Comma-separated list of relay URLs to notify via `requestCrawl` |
5757-5858-### Notifications
5959-6060-At least one channel should be configured for user notifications (password reset, email verification, etc.):
6161-6262-| Variable | Description |
6363-|----------|-------------|
6464-| `MAIL_FROM_ADDRESS` | Email sender address (enables email via sendmail) |
6565-| `MAIL_FROM_NAME` | Email sender name (default: "BSPDS") |
6666-| `SENDMAIL_PATH` | Path to sendmail binary (default: /usr/sbin/sendmail) |
6767-| `DISCORD_WEBHOOK_URL` | Discord webhook URL for notifications |
6868-| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
6969-| `SIGNAL_CLI_PATH` | Path to signal-cli binary |
7070-| `SIGNAL_SENDER_NUMBER` | Signal sender phone number (+1234567890 format) |
2424+See `.env.example` for all configuration options.
71257226## Development
73277474-```bash
7575-just # Show available commands
7676-just test # Run tests (auto-starts postgres/minio, runs nextest)
7777-just lint # Clippy + fmt check
7878-just db-reset # Drop and recreate local database
7979-```
8080-8181-## Web UI
8282-8383-BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
8484-8585-- Sign in and register new accounts
8686-- Manage app passwords
8787-- View and create invite codes
8888-- Update email and handle
8989-- Configure notification preferences
9090-- Browse their repository data
9191-9292-The frontend is built with svelte and deno, and is served directly by the PDS.
2828+Run `just` to see available commands.
93299430```bash
9595-just frontend-dev # Run frontend dev server
9696-just frontend-build # Build for production
9797-just frontend-test # Run frontend tests
9898-```
9999-100100-## Project Structure
101101-102102-```
103103-src/
104104- main.rs Server entrypoint
105105- lib.rs Router setup
106106- state.rs AppState (db pool, stores, rate limiters, circuit breakers)
107107- api/ XRPC handlers organized by namespace
108108- auth/ JWT authentication (ES256K per-user keys)
109109- oauth/ OAuth 2.1 provider (HS256 server-wide)
110110- repo/ PostgreSQL block store
111111- storage/ S3 blob storage
112112- sync/ Firehose, CAR export, crawler notifications
113113- notifications/ Multi-channel notification service
114114- plc/ PLC directory client
115115- circuit_breaker/ Circuit breaker for external services
116116- rate_limit/ Per-IP rate limiting
117117-frontend/ Svelte web UI (deno)
118118-tests/ Integration tests
119119-migrations/ SQLx migrations
3131+just test # run tests
3232+just lint # clippy + fmt
12033```
1213412235## License
+2-2
TODO.md
···201201- [x] DID Cache
202202 - [x] Implement caching layer for DID resolution (valkey).
203203 - [x] Handle cache invalidation/expiry.
204204- - [x] Graceful fallback to no-cache when Valkey unavailable.
204204+ - [x] Graceful fallback to no-cache when valkey unavailable.
205205- [x] Crawlers Service
206206 - [x] Implement `Crawlers` service (debounce notifications to relays).
207207 - [x] 20-minute notification debounce.
···237237 - [x] Per-IP rate limiting on OAuth revoke/introspect (30/min).
238238 - [x] Per-IP rate limiting on createAppPassword (10/min).
239239 - [x] Per-IP rate limiting on email endpoints (5/hour).
240240- - [x] Distributed rate limiting via Valkey/Redis (with in-memory fallback).
240240+ - [x] Distributed rate limiting via valkey (with in-memory fallback).
241241- [x] Circuit Breakers
242242 - [x] PLC directory circuit breaker (5 failures → open, 60s timeout).
243243 - [x] Relay notification circuit breaker (10 failures → open, 30s timeout).
···11+CREATE INDEX IF NOT EXISTS idx_records_repo_collection
22+ ON records(repo_id, collection);
33+44+CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created
55+ ON records(repo_id, collection, created_at DESC);
66+77+CREATE INDEX IF NOT EXISTS idx_users_email
88+ ON users(email)
99+ WHERE email IS NOT NULL;
1010+1111+CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user
1212+ ON blobs(created_by_user, created_at DESC);
1313+1414+CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq
1515+ ON repo_seq(did, seq DESC);
1616+1717+CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
1818+ ON app_passwords(user_id);
1919+2020+CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by
2121+ ON invite_codes(created_by_user);
···1010 SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
1111};
1212use chrono::{Duration, Utc};
1313-use common::{base_url, client, create_account_and_login};
1313+use common::{base_url, client, create_account_and_login, get_db_connection_string};
1414use k256::SecretKey;
1515use k256::ecdsa::{SigningKey, Signature, signature::Signer};
1616use rand::rngs::OsRng;
···906906907907 assert_eq!(create_res.status(), StatusCode::OK);
908908 let account: Value = create_res.json().await.unwrap();
909909- let refresh_jwt = account["refreshJwt"].as_str().unwrap().to_string();
909909+ let did = account["did"].as_str().unwrap();
910910+911911+ let conn_str = get_db_connection_string().await;
912912+ let pool = sqlx::postgres::PgPoolOptions::new()
913913+ .max_connections(2)
914914+ .connect(&conn_str)
915915+ .await
916916+ .expect("Failed to connect to test database");
917917+918918+ let verification_code: String = sqlx::query_scalar!(
919919+ "SELECT email_confirmation_code FROM users WHERE did = $1",
920920+ did
921921+ )
922922+ .fetch_one(&pool)
923923+ .await
924924+ .expect("Failed to get verification code")
925925+ .expect("No verification code found");
926926+927927+ let confirm_res = http_client
928928+ .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
929929+ .json(&json!({
930930+ "did": did,
931931+ "verificationCode": verification_code
932932+ }))
933933+ .send()
934934+ .await
935935+ .unwrap();
936936+937937+ assert_eq!(confirm_res.status(), StatusCode::OK);
938938+ let confirmed: Value = confirm_res.json().await.unwrap();
939939+ let refresh_jwt = confirmed["refreshJwt"].as_str().unwrap().to_string();
910940911941 let first_refresh = http_client
912942 .post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
···9801010 let url = base_url().await;
9811011 let http_client = client();
9821012983983- let ts = Utc::now().timestamp_millis();
984984- let handle = format!("del-sess-{}", ts);
985985- let email = format!("del-sess-{}@example.com", ts);
986986- let password = "test-password-123";
987987-988988- let create_res = http_client
989989- .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
990990- .json(&json!({
991991- "handle": handle,
992992- "email": email,
993993- "password": password
994994- }))
995995- .send()
996996- .await
997997- .unwrap();
998998-999999- let account: Value = create_res.json().await.unwrap();
10001000- let access_jwt = account["accessJwt"].as_str().unwrap().to_string();
10131013+ let (access_jwt, _did) = create_account_and_login(&http_client).await;
1001101410021015 let get_res = http_client
10031016 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
···10291042 let url = base_url().await;
10301043 let http_client = client();
1031104410321032- let ts = Utc::now().timestamp_millis();
10331033- let handle = format!("deact-jwt-{}", ts);
10341034- let email = format!("deact-jwt-{}@example.com", ts);
10351035- let password = "test-password-123";
10361036-10371037- let create_res = http_client
10381038- .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
10391039- .json(&json!({
10401040- "handle": handle,
10411041- "email": email,
10421042- "password": password
10431043- }))
10441044- .send()
10451045- .await
10461046- .unwrap();
10471047-10481048- let account: Value = create_res.json().await.unwrap();
10491049- let access_jwt = account["accessJwt"].as_str().unwrap().to_string();
10451045+ let (access_jwt, _did) = create_account_and_login(&http_client).await;
1050104610511047 let deact_res = http_client
10521048 .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
+545-52
tests/lifecycle_record.rs
···664664}
665665666666#[tokio::test]
667667-async fn test_list_records_pagination() {
668668- let client = client();
669669- let (did, jwt) = setup_new_user("list-pagination").await;
670670-671671- for i in 0..5 {
672672- tokio::time::sleep(Duration::from_millis(50)).await;
673673- create_post(&client, &did, &jwt, &format!("Post number {}", i)).await;
674674- }
675675-676676- let list_res = client
677677- .get(format!(
678678- "{}/xrpc/com.atproto.repo.listRecords",
679679- base_url().await
680680- ))
681681- .query(&[
682682- ("repo", did.as_str()),
683683- ("collection", "app.bsky.feed.post"),
684684- ("limit", "2"),
685685- ])
686686- .send()
687687- .await
688688- .expect("Failed to list records");
689689-690690- assert_eq!(list_res.status(), StatusCode::OK);
691691- let list_body: Value = list_res.json().await.unwrap();
692692- let records = list_body["records"].as_array().unwrap();
693693- assert_eq!(records.len(), 2, "Should return 2 records with limit=2");
694694-695695- if let Some(cursor) = list_body["cursor"].as_str() {
696696- let list_page2_res = client
697697- .get(format!(
698698- "{}/xrpc/com.atproto.repo.listRecords",
699699- base_url().await
700700- ))
701701- .query(&[
702702- ("repo", did.as_str()),
703703- ("collection", "app.bsky.feed.post"),
704704- ("limit", "2"),
705705- ("cursor", cursor),
706706- ])
707707- .send()
708708- .await
709709- .expect("Failed to list records page 2");
710710-711711- assert_eq!(list_page2_res.status(), StatusCode::OK);
712712- let page2_body: Value = list_page2_res.json().await.unwrap();
713713- let page2_records = page2_body["records"].as_array().unwrap();
714714- assert_eq!(page2_records.len(), 2, "Page 2 should have 2 more records");
715715- }
716716-}
717717-718718-#[tokio::test]
719667async fn test_apply_writes_batch_lifecycle() {
720668 let client = client();
721669 let (did, jwt) = setup_new_user("apply-writes-batch").await;
···885833 "Batch-deleted post should be gone"
886834 );
887835}
836836+837837+async fn create_post_with_rkey(
838838+ client: &reqwest::Client,
839839+ did: &str,
840840+ jwt: &str,
841841+ rkey: &str,
842842+ text: &str,
843843+) -> (String, String) {
844844+ let payload = json!({
845845+ "repo": did,
846846+ "collection": "app.bsky.feed.post",
847847+ "rkey": rkey,
848848+ "record": {
849849+ "$type": "app.bsky.feed.post",
850850+ "text": text,
851851+ "createdAt": Utc::now().to_rfc3339()
852852+ }
853853+ });
854854+855855+ let res = client
856856+ .post(format!(
857857+ "{}/xrpc/com.atproto.repo.putRecord",
858858+ base_url().await
859859+ ))
860860+ .bearer_auth(jwt)
861861+ .json(&payload)
862862+ .send()
863863+ .await
864864+ .expect("Failed to create record");
865865+866866+ assert_eq!(res.status(), StatusCode::OK);
867867+ let body: Value = res.json().await.unwrap();
868868+ (
869869+ body["uri"].as_str().unwrap().to_string(),
870870+ body["cid"].as_str().unwrap().to_string(),
871871+ )
872872+}
873873+874874+#[tokio::test]
875875+async fn test_list_records_default_order() {
876876+ let client = client();
877877+ let (did, jwt) = setup_new_user("list-default-order").await;
878878+879879+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
880880+ tokio::time::sleep(Duration::from_millis(50)).await;
881881+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
882882+ tokio::time::sleep(Duration::from_millis(50)).await;
883883+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
884884+885885+ let res = client
886886+ .get(format!(
887887+ "{}/xrpc/com.atproto.repo.listRecords",
888888+ base_url().await
889889+ ))
890890+ .query(&[
891891+ ("repo", did.as_str()),
892892+ ("collection", "app.bsky.feed.post"),
893893+ ])
894894+ .send()
895895+ .await
896896+ .expect("Failed to list records");
897897+898898+ assert_eq!(res.status(), StatusCode::OK);
899899+ let body: Value = res.json().await.unwrap();
900900+ let records = body["records"].as_array().unwrap();
901901+902902+ assert_eq!(records.len(), 3);
903903+ let rkeys: Vec<&str> = records
904904+ .iter()
905905+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
906906+ .collect();
907907+908908+ assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
909909+}
910910+911911+#[tokio::test]
912912+async fn test_list_records_reverse_true() {
913913+ let client = client();
914914+ let (did, jwt) = setup_new_user("list-reverse").await;
915915+916916+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
917917+ tokio::time::sleep(Duration::from_millis(50)).await;
918918+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
919919+ tokio::time::sleep(Duration::from_millis(50)).await;
920920+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
921921+922922+ let res = client
923923+ .get(format!(
924924+ "{}/xrpc/com.atproto.repo.listRecords",
925925+ base_url().await
926926+ ))
927927+ .query(&[
928928+ ("repo", did.as_str()),
929929+ ("collection", "app.bsky.feed.post"),
930930+ ("reverse", "true"),
931931+ ])
932932+ .send()
933933+ .await
934934+ .expect("Failed to list records");
935935+936936+ assert_eq!(res.status(), StatusCode::OK);
937937+ let body: Value = res.json().await.unwrap();
938938+ let records = body["records"].as_array().unwrap();
939939+940940+ let rkeys: Vec<&str> = records
941941+ .iter()
942942+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
943943+ .collect();
944944+945945+ assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
946946+}
947947+948948+#[tokio::test]
949949+async fn test_list_records_cursor_pagination() {
950950+ let client = client();
951951+ let (did, jwt) = setup_new_user("list-cursor").await;
952952+953953+ for i in 0..5 {
954954+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
955955+ tokio::time::sleep(Duration::from_millis(50)).await;
956956+ }
957957+958958+ let res = client
959959+ .get(format!(
960960+ "{}/xrpc/com.atproto.repo.listRecords",
961961+ base_url().await
962962+ ))
963963+ .query(&[
964964+ ("repo", did.as_str()),
965965+ ("collection", "app.bsky.feed.post"),
966966+ ("limit", "2"),
967967+ ])
968968+ .send()
969969+ .await
970970+ .expect("Failed to list records");
971971+972972+ assert_eq!(res.status(), StatusCode::OK);
973973+ let body: Value = res.json().await.unwrap();
974974+ let records = body["records"].as_array().unwrap();
975975+ assert_eq!(records.len(), 2);
976976+977977+ let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
978978+979979+ let res2 = client
980980+ .get(format!(
981981+ "{}/xrpc/com.atproto.repo.listRecords",
982982+ base_url().await
983983+ ))
984984+ .query(&[
985985+ ("repo", did.as_str()),
986986+ ("collection", "app.bsky.feed.post"),
987987+ ("limit", "2"),
988988+ ("cursor", cursor),
989989+ ])
990990+ .send()
991991+ .await
992992+ .expect("Failed to list records with cursor");
993993+994994+ assert_eq!(res2.status(), StatusCode::OK);
995995+ let body2: Value = res2.json().await.unwrap();
996996+ let records2 = body2["records"].as_array().unwrap();
997997+ assert_eq!(records2.len(), 2);
998998+999999+ let all_uris: Vec<&str> = records
10001000+ .iter()
10011001+ .chain(records2.iter())
10021002+ .map(|r| r["uri"].as_str().unwrap())
10031003+ .collect();
10041004+ let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
10051005+ assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
10061006+}
10071007+10081008+#[tokio::test]
10091009+async fn test_list_records_rkey_start() {
10101010+ let client = client();
10111011+ let (did, jwt) = setup_new_user("list-rkey-start").await;
10121012+10131013+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
10141014+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
10151015+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
10161016+ create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
10171017+10181018+ let res = client
10191019+ .get(format!(
10201020+ "{}/xrpc/com.atproto.repo.listRecords",
10211021+ base_url().await
10221022+ ))
10231023+ .query(&[
10241024+ ("repo", did.as_str()),
10251025+ ("collection", "app.bsky.feed.post"),
10261026+ ("rkeyStart", "bbbb"),
10271027+ ("reverse", "true"),
10281028+ ])
10291029+ .send()
10301030+ .await
10311031+ .expect("Failed to list records");
10321032+10331033+ assert_eq!(res.status(), StatusCode::OK);
10341034+ let body: Value = res.json().await.unwrap();
10351035+ let records = body["records"].as_array().unwrap();
10361036+10371037+ let rkeys: Vec<&str> = records
10381038+ .iter()
10391039+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
10401040+ .collect();
10411041+10421042+ for rkey in &rkeys {
10431043+ assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
10441044+ }
10451045+}
10461046+10471047+#[tokio::test]
10481048+async fn test_list_records_rkey_end() {
10491049+ let client = client();
10501050+ let (did, jwt) = setup_new_user("list-rkey-end").await;
10511051+10521052+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
10531053+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
10541054+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
10551055+ create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
10561056+10571057+ let res = client
10581058+ .get(format!(
10591059+ "{}/xrpc/com.atproto.repo.listRecords",
10601060+ base_url().await
10611061+ ))
10621062+ .query(&[
10631063+ ("repo", did.as_str()),
10641064+ ("collection", "app.bsky.feed.post"),
10651065+ ("rkeyEnd", "cccc"),
10661066+ ("reverse", "true"),
10671067+ ])
10681068+ .send()
10691069+ .await
10701070+ .expect("Failed to list records");
10711071+10721072+ assert_eq!(res.status(), StatusCode::OK);
10731073+ let body: Value = res.json().await.unwrap();
10741074+ let records = body["records"].as_array().unwrap();
10751075+10761076+ let rkeys: Vec<&str> = records
10771077+ .iter()
10781078+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
10791079+ .collect();
10801080+10811081+ for rkey in &rkeys {
10821082+ assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
10831083+ }
10841084+}
10851085+10861086+#[tokio::test]
10871087+async fn test_list_records_rkey_range() {
10881088+ let client = client();
10891089+ let (did, jwt) = setup_new_user("list-rkey-range").await;
10901090+10911091+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
10921092+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
10931093+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
10941094+ create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
10951095+ create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
10961096+10971097+ let res = client
10981098+ .get(format!(
10991099+ "{}/xrpc/com.atproto.repo.listRecords",
11001100+ base_url().await
11011101+ ))
11021102+ .query(&[
11031103+ ("repo", did.as_str()),
11041104+ ("collection", "app.bsky.feed.post"),
11051105+ ("rkeyStart", "bbbb"),
11061106+ ("rkeyEnd", "dddd"),
11071107+ ("reverse", "true"),
11081108+ ])
11091109+ .send()
11101110+ .await
11111111+ .expect("Failed to list records");
11121112+11131113+ assert_eq!(res.status(), StatusCode::OK);
11141114+ let body: Value = res.json().await.unwrap();
11151115+ let records = body["records"].as_array().unwrap();
11161116+11171117+ let rkeys: Vec<&str> = records
11181118+ .iter()
11191119+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
11201120+ .collect();
11211121+11221122+ for rkey in &rkeys {
11231123+ assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
11241124+ }
11251125+ assert!(!rkeys.is_empty(), "Should have at least some records in range");
11261126+}
11271127+11281128+#[tokio::test]
11291129+async fn test_list_records_limit_clamping_max() {
11301130+ let client = client();
11311131+ let (did, jwt) = setup_new_user("list-limit-max").await;
11321132+11331133+ for i in 0..5 {
11341134+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
11351135+ }
11361136+11371137+ let res = client
11381138+ .get(format!(
11391139+ "{}/xrpc/com.atproto.repo.listRecords",
11401140+ base_url().await
11411141+ ))
11421142+ .query(&[
11431143+ ("repo", did.as_str()),
11441144+ ("collection", "app.bsky.feed.post"),
11451145+ ("limit", "1000"),
11461146+ ])
11471147+ .send()
11481148+ .await
11491149+ .expect("Failed to list records");
11501150+11511151+ assert_eq!(res.status(), StatusCode::OK);
11521152+ let body: Value = res.json().await.unwrap();
11531153+ let records = body["records"].as_array().unwrap();
11541154+ assert!(records.len() <= 100, "Limit should be clamped to max 100");
11551155+}
11561156+11571157+#[tokio::test]
11581158+async fn test_list_records_limit_clamping_min() {
11591159+ let client = client();
11601160+ let (did, jwt) = setup_new_user("list-limit-min").await;
11611161+11621162+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
11631163+11641164+ let res = client
11651165+ .get(format!(
11661166+ "{}/xrpc/com.atproto.repo.listRecords",
11671167+ base_url().await
11681168+ ))
11691169+ .query(&[
11701170+ ("repo", did.as_str()),
11711171+ ("collection", "app.bsky.feed.post"),
11721172+ ("limit", "0"),
11731173+ ])
11741174+ .send()
11751175+ .await
11761176+ .expect("Failed to list records");
11771177+11781178+ assert_eq!(res.status(), StatusCode::OK);
11791179+ let body: Value = res.json().await.unwrap();
11801180+ let records = body["records"].as_array().unwrap();
11811181+ assert!(records.len() >= 1, "Limit should be clamped to min 1");
11821182+}
11831183+11841184+#[tokio::test]
11851185+async fn test_list_records_empty_collection() {
11861186+ let client = client();
11871187+ let (did, _jwt) = setup_new_user("list-empty").await;
11881188+11891189+ let res = client
11901190+ .get(format!(
11911191+ "{}/xrpc/com.atproto.repo.listRecords",
11921192+ base_url().await
11931193+ ))
11941194+ .query(&[
11951195+ ("repo", did.as_str()),
11961196+ ("collection", "app.bsky.feed.post"),
11971197+ ])
11981198+ .send()
11991199+ .await
12001200+ .expect("Failed to list records");
12011201+12021202+ assert_eq!(res.status(), StatusCode::OK);
12031203+ let body: Value = res.json().await.unwrap();
12041204+ let records = body["records"].as_array().unwrap();
12051205+ assert!(records.is_empty(), "Empty collection should return empty array");
12061206+ assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
12071207+}
12081208+12091209+#[tokio::test]
12101210+async fn test_list_records_exact_limit() {
12111211+ let client = client();
12121212+ let (did, jwt) = setup_new_user("list-exact-limit").await;
12131213+12141214+ for i in 0..10 {
12151215+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
12161216+ }
12171217+12181218+ let res = client
12191219+ .get(format!(
12201220+ "{}/xrpc/com.atproto.repo.listRecords",
12211221+ base_url().await
12221222+ ))
12231223+ .query(&[
12241224+ ("repo", did.as_str()),
12251225+ ("collection", "app.bsky.feed.post"),
12261226+ ("limit", "5"),
12271227+ ])
12281228+ .send()
12291229+ .await
12301230+ .expect("Failed to list records");
12311231+12321232+ assert_eq!(res.status(), StatusCode::OK);
12331233+ let body: Value = res.json().await.unwrap();
12341234+ let records = body["records"].as_array().unwrap();
12351235+ assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
12361236+}
12371237+12381238+#[tokio::test]
12391239+async fn test_list_records_cursor_exhaustion() {
12401240+ let client = client();
12411241+ let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
12421242+12431243+ for i in 0..3 {
12441244+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
12451245+ }
12461246+12471247+ let res = client
12481248+ .get(format!(
12491249+ "{}/xrpc/com.atproto.repo.listRecords",
12501250+ base_url().await
12511251+ ))
12521252+ .query(&[
12531253+ ("repo", did.as_str()),
12541254+ ("collection", "app.bsky.feed.post"),
12551255+ ("limit", "10"),
12561256+ ])
12571257+ .send()
12581258+ .await
12591259+ .expect("Failed to list records");
12601260+12611261+ assert_eq!(res.status(), StatusCode::OK);
12621262+ let body: Value = res.json().await.unwrap();
12631263+ let records = body["records"].as_array().unwrap();
12641264+ assert_eq!(records.len(), 3);
12651265+}
12661266+12671267+#[tokio::test]
12681268+async fn test_list_records_repo_not_found() {
12691269+ let client = client();
12701270+12711271+ let res = client
12721272+ .get(format!(
12731273+ "{}/xrpc/com.atproto.repo.listRecords",
12741274+ base_url().await
12751275+ ))
12761276+ .query(&[
12771277+ ("repo", "did:plc:nonexistent12345"),
12781278+ ("collection", "app.bsky.feed.post"),
12791279+ ])
12801280+ .send()
12811281+ .await
12821282+ .expect("Failed to list records");
12831283+12841284+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
12851285+}
12861286+12871287+#[tokio::test]
12881288+async fn test_list_records_includes_cid() {
12891289+ let client = client();
12901290+ let (did, jwt) = setup_new_user("list-includes-cid").await;
12911291+12921292+ create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
12931293+12941294+ let res = client
12951295+ .get(format!(
12961296+ "{}/xrpc/com.atproto.repo.listRecords",
12971297+ base_url().await
12981298+ ))
12991299+ .query(&[
13001300+ ("repo", did.as_str()),
13011301+ ("collection", "app.bsky.feed.post"),
13021302+ ])
13031303+ .send()
13041304+ .await
13051305+ .expect("Failed to list records");
13061306+13071307+ assert_eq!(res.status(), StatusCode::OK);
13081308+ let body: Value = res.json().await.unwrap();
13091309+ let records = body["records"].as_array().unwrap();
13101310+13111311+ for record in records {
13121312+ assert!(record["uri"].is_string(), "Record should have uri");
13131313+ assert!(record["cid"].is_string(), "Record should have cid");
13141314+ assert!(record["value"].is_object(), "Record should have value");
13151315+ let cid = record["cid"].as_str().unwrap();
13161316+ assert!(cid.starts_with("bafy"), "CID should be valid");
13171317+ }
13181318+}
13191319+13201320+#[tokio::test]
13211321+async fn test_list_records_cursor_with_reverse() {
13221322+ let client = client();
13231323+ let (did, jwt) = setup_new_user("list-cursor-reverse").await;
13241324+13251325+ for i in 0..5 {
13261326+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
13271327+ }
13281328+13291329+ let res = client
13301330+ .get(format!(
13311331+ "{}/xrpc/com.atproto.repo.listRecords",
13321332+ base_url().await
13331333+ ))
13341334+ .query(&[
13351335+ ("repo", did.as_str()),
13361336+ ("collection", "app.bsky.feed.post"),
13371337+ ("limit", "2"),
13381338+ ("reverse", "true"),
13391339+ ])
13401340+ .send()
13411341+ .await
13421342+ .expect("Failed to list records");
13431343+13441344+ assert_eq!(res.status(), StatusCode::OK);
13451345+ let body: Value = res.json().await.unwrap();
13461346+ let records = body["records"].as_array().unwrap();
13471347+ let first_rkeys: Vec<&str> = records
13481348+ .iter()
13491349+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
13501350+ .collect();
13511351+13521352+ assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
13531353+13541354+ if let Some(cursor) = body["cursor"].as_str() {
13551355+ let res2 = client
13561356+ .get(format!(
13571357+ "{}/xrpc/com.atproto.repo.listRecords",
13581358+ base_url().await
13591359+ ))
13601360+ .query(&[
13611361+ ("repo", did.as_str()),
13621362+ ("collection", "app.bsky.feed.post"),
13631363+ ("limit", "2"),
13641364+ ("reverse", "true"),
13651365+ ("cursor", cursor),
13661366+ ])
13671367+ .send()
13681368+ .await
13691369+ .expect("Failed to list records with cursor");
13701370+13711371+ let body2: Value = res2.json().await.unwrap();
13721372+ let records2 = body2["records"].as_array().unwrap();
13731373+ let second_rkeys: Vec<&str> = records2
13741374+ .iter()
13751375+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
13761376+ .collect();
13771377+13781378+ assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
13791379+ }
13801380+}
+18-7
tests/lifecycle_session.rs
···5858 .await
5959 .expect("Failed to create account");
6060 assert_eq!(create_res.status(), StatusCode::OK);
6161+ let create_body: Value = create_res.json().await.unwrap();
6262+ let did = create_body["did"].as_str().unwrap();
6363+6464+ let _ = verify_new_account(&client, did).await;
61656266 let login_payload = json!({
6367 "identifier": handle,
···128132 "email": email,
129133 "password": password
130134 });
131131- client
135135+ let create_res = client
132136 .post(format!(
133137 "{}/xrpc/com.atproto.server.createAccount",
134138 base_url().await
···137141 .send()
138142 .await
139143 .expect("Failed to create account");
144144+ let create_body: Value = create_res.json().await.unwrap();
145145+ let did = create_body["did"].as_str().unwrap();
146146+147147+ let _ = verify_new_account(&client, did).await;
140148141149 let login_payload = json!({
142150 "identifier": handle,
···209217210218 assert_eq!(create_res.status(), StatusCode::OK);
211219 let account: Value = create_res.json().await.unwrap();
212212- let jwt = account["accessJwt"].as_str().unwrap();
220220+ let did = account["did"].as_str().unwrap();
221221+222222+ let jwt = verify_new_account(&client, did).await;
213223214224 let create_app_pass_res = client
215225 .post(format!(
216226 "{}/xrpc/com.atproto.server.createAppPassword",
217227 base_url().await
218228 ))
219219- .bearer_auth(jwt)
229229+ .bearer_auth(&jwt)
220230 .json(&json!({ "name": "Test App" }))
221231 .send()
222232 .await
···232242 "{}/xrpc/com.atproto.server.listAppPasswords",
233243 base_url().await
234244 ))
235235- .bearer_auth(jwt)
245245+ .bearer_auth(&jwt)
236246 .send()
237247 .await
238248 .expect("Failed to list app passwords");
···263273 "{}/xrpc/com.atproto.server.revokeAppPassword",
264274 base_url().await
265275 ))
266266- .bearer_auth(jwt)
276276+ .bearer_auth(&jwt)
267277 .json(&json!({ "name": "Test App" }))
268278 .send()
269279 .await
···295305 "{}/xrpc/com.atproto.server.listAppPasswords",
296306 base_url().await
297307 ))
298298- .bearer_auth(jwt)
308308+ .bearer_auth(&jwt)
299309 .send()
300310 .await
301311 .expect("Failed to list after revoke");
···330340 assert_eq!(create_res.status(), StatusCode::OK);
331341 let account: Value = create_res.json().await.unwrap();
332342 let did = account["did"].as_str().unwrap().to_string();
333333- let jwt = account["accessJwt"].as_str().unwrap().to_string();
343343+344344+ let jwt = verify_new_account(&client, &did).await;
334345335346 let (post_uri, _) = create_post(&client, &did, &jwt, "Post before deactivation").await;
336347 let post_rkey = post_uri.split('/').last().unwrap();
+2-1
tests/lifecycle_social.rs
···441441 assert_eq!(create_account_res.status(), StatusCode::OK);
442442 let account_body: Value = create_account_res.json().await.unwrap();
443443 let did = account_body["did"].as_str().unwrap().to_string();
444444- let access_jwt = account_body["accessJwt"].as_str().unwrap().to_string();
444444+445445+ let access_jwt = verify_new_account(&client, &did).await;
445446446447 let get_session_res = client
447448 .get(format!(
-554
tests/list_records_pagination.rs
···11-mod common;
22-mod helpers;
33-use common::*;
44-use helpers::*;
55-66-use chrono::Utc;
77-use reqwest::StatusCode;
88-use serde_json::{Value, json};
99-use std::time::Duration;
1010-1111-async fn create_post_with_rkey(
1212- client: &reqwest::Client,
1313- did: &str,
1414- jwt: &str,
1515- rkey: &str,
1616- text: &str,
1717-) -> (String, String) {
1818- let payload = json!({
1919- "repo": did,
2020- "collection": "app.bsky.feed.post",
2121- "rkey": rkey,
2222- "record": {
2323- "$type": "app.bsky.feed.post",
2424- "text": text,
2525- "createdAt": Utc::now().to_rfc3339()
2626- }
2727- });
2828-2929- let res = client
3030- .post(format!(
3131- "{}/xrpc/com.atproto.repo.putRecord",
3232- base_url().await
3333- ))
3434- .bearer_auth(jwt)
3535- .json(&payload)
3636- .send()
3737- .await
3838- .expect("Failed to create record");
3939-4040- assert_eq!(res.status(), StatusCode::OK);
4141- let body: Value = res.json().await.unwrap();
4242- (
4343- body["uri"].as_str().unwrap().to_string(),
4444- body["cid"].as_str().unwrap().to_string(),
4545- )
4646-}
4747-4848-#[tokio::test]
4949-async fn test_list_records_default_order() {
5050- let client = client();
5151- let (did, jwt) = setup_new_user("list-default-order").await;
5252-5353- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
5454- tokio::time::sleep(Duration::from_millis(50)).await;
5555- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
5656- tokio::time::sleep(Duration::from_millis(50)).await;
5757- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
5858-5959- let res = client
6060- .get(format!(
6161- "{}/xrpc/com.atproto.repo.listRecords",
6262- base_url().await
6363- ))
6464- .query(&[
6565- ("repo", did.as_str()),
6666- ("collection", "app.bsky.feed.post"),
6767- ])
6868- .send()
6969- .await
7070- .expect("Failed to list records");
7171-7272- assert_eq!(res.status(), StatusCode::OK);
7373- let body: Value = res.json().await.unwrap();
7474- let records = body["records"].as_array().unwrap();
7575-7676- assert_eq!(records.len(), 3);
7777- let rkeys: Vec<&str> = records
7878- .iter()
7979- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
8080- .collect();
8181-8282- assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
8383-}
8484-8585-#[tokio::test]
8686-async fn test_list_records_reverse_true() {
8787- let client = client();
8888- let (did, jwt) = setup_new_user("list-reverse").await;
8989-9090- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
9191- tokio::time::sleep(Duration::from_millis(50)).await;
9292- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
9393- tokio::time::sleep(Duration::from_millis(50)).await;
9494- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
9595-9696- let res = client
9797- .get(format!(
9898- "{}/xrpc/com.atproto.repo.listRecords",
9999- base_url().await
100100- ))
101101- .query(&[
102102- ("repo", did.as_str()),
103103- ("collection", "app.bsky.feed.post"),
104104- ("reverse", "true"),
105105- ])
106106- .send()
107107- .await
108108- .expect("Failed to list records");
109109-110110- assert_eq!(res.status(), StatusCode::OK);
111111- let body: Value = res.json().await.unwrap();
112112- let records = body["records"].as_array().unwrap();
113113-114114- let rkeys: Vec<&str> = records
115115- .iter()
116116- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
117117- .collect();
118118-119119- assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
120120-}
121121-122122-#[tokio::test]
123123-async fn test_list_records_cursor_pagination() {
124124- let client = client();
125125- let (did, jwt) = setup_new_user("list-cursor").await;
126126-127127- for i in 0..5 {
128128- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
129129- tokio::time::sleep(Duration::from_millis(50)).await;
130130- }
131131-132132- let res = client
133133- .get(format!(
134134- "{}/xrpc/com.atproto.repo.listRecords",
135135- base_url().await
136136- ))
137137- .query(&[
138138- ("repo", did.as_str()),
139139- ("collection", "app.bsky.feed.post"),
140140- ("limit", "2"),
141141- ])
142142- .send()
143143- .await
144144- .expect("Failed to list records");
145145-146146- assert_eq!(res.status(), StatusCode::OK);
147147- let body: Value = res.json().await.unwrap();
148148- let records = body["records"].as_array().unwrap();
149149- assert_eq!(records.len(), 2);
150150-151151- let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
152152-153153- let res2 = client
154154- .get(format!(
155155- "{}/xrpc/com.atproto.repo.listRecords",
156156- base_url().await
157157- ))
158158- .query(&[
159159- ("repo", did.as_str()),
160160- ("collection", "app.bsky.feed.post"),
161161- ("limit", "2"),
162162- ("cursor", cursor),
163163- ])
164164- .send()
165165- .await
166166- .expect("Failed to list records with cursor");
167167-168168- assert_eq!(res2.status(), StatusCode::OK);
169169- let body2: Value = res2.json().await.unwrap();
170170- let records2 = body2["records"].as_array().unwrap();
171171- assert_eq!(records2.len(), 2);
172172-173173- let all_uris: Vec<&str> = records
174174- .iter()
175175- .chain(records2.iter())
176176- .map(|r| r["uri"].as_str().unwrap())
177177- .collect();
178178- let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
179179- assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
180180-}
181181-182182-#[tokio::test]
183183-async fn test_list_records_rkey_start() {
184184- let client = client();
185185- let (did, jwt) = setup_new_user("list-rkey-start").await;
186186-187187- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
188188- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
189189- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
190190- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
191191-192192- let res = client
193193- .get(format!(
194194- "{}/xrpc/com.atproto.repo.listRecords",
195195- base_url().await
196196- ))
197197- .query(&[
198198- ("repo", did.as_str()),
199199- ("collection", "app.bsky.feed.post"),
200200- ("rkeyStart", "bbbb"),
201201- ("reverse", "true"),
202202- ])
203203- .send()
204204- .await
205205- .expect("Failed to list records");
206206-207207- assert_eq!(res.status(), StatusCode::OK);
208208- let body: Value = res.json().await.unwrap();
209209- let records = body["records"].as_array().unwrap();
210210-211211- let rkeys: Vec<&str> = records
212212- .iter()
213213- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
214214- .collect();
215215-216216- for rkey in &rkeys {
217217- assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
218218- }
219219-}
220220-221221-#[tokio::test]
222222-async fn test_list_records_rkey_end() {
223223- let client = client();
224224- let (did, jwt) = setup_new_user("list-rkey-end").await;
225225-226226- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
227227- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
228228- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
229229- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
230230-231231- let res = client
232232- .get(format!(
233233- "{}/xrpc/com.atproto.repo.listRecords",
234234- base_url().await
235235- ))
236236- .query(&[
237237- ("repo", did.as_str()),
238238- ("collection", "app.bsky.feed.post"),
239239- ("rkeyEnd", "cccc"),
240240- ("reverse", "true"),
241241- ])
242242- .send()
243243- .await
244244- .expect("Failed to list records");
245245-246246- assert_eq!(res.status(), StatusCode::OK);
247247- let body: Value = res.json().await.unwrap();
248248- let records = body["records"].as_array().unwrap();
249249-250250- let rkeys: Vec<&str> = records
251251- .iter()
252252- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
253253- .collect();
254254-255255- for rkey in &rkeys {
256256- assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
257257- }
258258-}
259259-260260-#[tokio::test]
261261-async fn test_list_records_rkey_range() {
262262- let client = client();
263263- let (did, jwt) = setup_new_user("list-rkey-range").await;
264264-265265- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
266266- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
267267- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
268268- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
269269- create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
270270-271271- let res = client
272272- .get(format!(
273273- "{}/xrpc/com.atproto.repo.listRecords",
274274- base_url().await
275275- ))
276276- .query(&[
277277- ("repo", did.as_str()),
278278- ("collection", "app.bsky.feed.post"),
279279- ("rkeyStart", "bbbb"),
280280- ("rkeyEnd", "dddd"),
281281- ("reverse", "true"),
282282- ])
283283- .send()
284284- .await
285285- .expect("Failed to list records");
286286-287287- assert_eq!(res.status(), StatusCode::OK);
288288- let body: Value = res.json().await.unwrap();
289289- let records = body["records"].as_array().unwrap();
290290-291291- let rkeys: Vec<&str> = records
292292- .iter()
293293- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
294294- .collect();
295295-296296- for rkey in &rkeys {
297297- assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
298298- }
299299- assert!(!rkeys.is_empty(), "Should have at least some records in range");
300300-}
301301-302302-#[tokio::test]
303303-async fn test_list_records_limit_clamping_max() {
304304- let client = client();
305305- let (did, jwt) = setup_new_user("list-limit-max").await;
306306-307307- for i in 0..5 {
308308- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
309309- }
310310-311311- let res = client
312312- .get(format!(
313313- "{}/xrpc/com.atproto.repo.listRecords",
314314- base_url().await
315315- ))
316316- .query(&[
317317- ("repo", did.as_str()),
318318- ("collection", "app.bsky.feed.post"),
319319- ("limit", "1000"),
320320- ])
321321- .send()
322322- .await
323323- .expect("Failed to list records");
324324-325325- assert_eq!(res.status(), StatusCode::OK);
326326- let body: Value = res.json().await.unwrap();
327327- let records = body["records"].as_array().unwrap();
328328- assert!(records.len() <= 100, "Limit should be clamped to max 100");
329329-}
330330-331331-#[tokio::test]
332332-async fn test_list_records_limit_clamping_min() {
333333- let client = client();
334334- let (did, jwt) = setup_new_user("list-limit-min").await;
335335-336336- create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
337337-338338- let res = client
339339- .get(format!(
340340- "{}/xrpc/com.atproto.repo.listRecords",
341341- base_url().await
342342- ))
343343- .query(&[
344344- ("repo", did.as_str()),
345345- ("collection", "app.bsky.feed.post"),
346346- ("limit", "0"),
347347- ])
348348- .send()
349349- .await
350350- .expect("Failed to list records");
351351-352352- assert_eq!(res.status(), StatusCode::OK);
353353- let body: Value = res.json().await.unwrap();
354354- let records = body["records"].as_array().unwrap();
355355- assert!(records.len() >= 1, "Limit should be clamped to min 1");
356356-}
357357-358358-#[tokio::test]
359359-async fn test_list_records_empty_collection() {
360360- let client = client();
361361- let (did, _jwt) = setup_new_user("list-empty").await;
362362-363363- let res = client
364364- .get(format!(
365365- "{}/xrpc/com.atproto.repo.listRecords",
366366- base_url().await
367367- ))
368368- .query(&[
369369- ("repo", did.as_str()),
370370- ("collection", "app.bsky.feed.post"),
371371- ])
372372- .send()
373373- .await
374374- .expect("Failed to list records");
375375-376376- assert_eq!(res.status(), StatusCode::OK);
377377- let body: Value = res.json().await.unwrap();
378378- let records = body["records"].as_array().unwrap();
379379- assert!(records.is_empty(), "Empty collection should return empty array");
380380- assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
381381-}
382382-383383-#[tokio::test]
384384-async fn test_list_records_exact_limit() {
385385- let client = client();
386386- let (did, jwt) = setup_new_user("list-exact-limit").await;
387387-388388- for i in 0..10 {
389389- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
390390- }
391391-392392- let res = client
393393- .get(format!(
394394- "{}/xrpc/com.atproto.repo.listRecords",
395395- base_url().await
396396- ))
397397- .query(&[
398398- ("repo", did.as_str()),
399399- ("collection", "app.bsky.feed.post"),
400400- ("limit", "5"),
401401- ])
402402- .send()
403403- .await
404404- .expect("Failed to list records");
405405-406406- assert_eq!(res.status(), StatusCode::OK);
407407- let body: Value = res.json().await.unwrap();
408408- let records = body["records"].as_array().unwrap();
409409- assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
410410-}
411411-412412-#[tokio::test]
413413-async fn test_list_records_cursor_exhaustion() {
414414- let client = client();
415415- let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
416416-417417- for i in 0..3 {
418418- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
419419- }
420420-421421- let res = client
422422- .get(format!(
423423- "{}/xrpc/com.atproto.repo.listRecords",
424424- base_url().await
425425- ))
426426- .query(&[
427427- ("repo", did.as_str()),
428428- ("collection", "app.bsky.feed.post"),
429429- ("limit", "10"),
430430- ])
431431- .send()
432432- .await
433433- .expect("Failed to list records");
434434-435435- assert_eq!(res.status(), StatusCode::OK);
436436- let body: Value = res.json().await.unwrap();
437437- let records = body["records"].as_array().unwrap();
438438- assert_eq!(records.len(), 3);
439439-}
440440-441441-#[tokio::test]
442442-async fn test_list_records_repo_not_found() {
443443- let client = client();
444444-445445- let res = client
446446- .get(format!(
447447- "{}/xrpc/com.atproto.repo.listRecords",
448448- base_url().await
449449- ))
450450- .query(&[
451451- ("repo", "did:plc:nonexistent12345"),
452452- ("collection", "app.bsky.feed.post"),
453453- ])
454454- .send()
455455- .await
456456- .expect("Failed to list records");
457457-458458- assert_eq!(res.status(), StatusCode::NOT_FOUND);
459459-}
460460-461461-#[tokio::test]
462462-async fn test_list_records_includes_cid() {
463463- let client = client();
464464- let (did, jwt) = setup_new_user("list-includes-cid").await;
465465-466466- create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
467467-468468- let res = client
469469- .get(format!(
470470- "{}/xrpc/com.atproto.repo.listRecords",
471471- base_url().await
472472- ))
473473- .query(&[
474474- ("repo", did.as_str()),
475475- ("collection", "app.bsky.feed.post"),
476476- ])
477477- .send()
478478- .await
479479- .expect("Failed to list records");
480480-481481- assert_eq!(res.status(), StatusCode::OK);
482482- let body: Value = res.json().await.unwrap();
483483- let records = body["records"].as_array().unwrap();
484484-485485- for record in records {
486486- assert!(record["uri"].is_string(), "Record should have uri");
487487- assert!(record["cid"].is_string(), "Record should have cid");
488488- assert!(record["value"].is_object(), "Record should have value");
489489- let cid = record["cid"].as_str().unwrap();
490490- assert!(cid.starts_with("bafy"), "CID should be valid");
491491- }
492492-}
493493-494494-#[tokio::test]
495495-async fn test_list_records_cursor_with_reverse() {
496496- let client = client();
497497- let (did, jwt) = setup_new_user("list-cursor-reverse").await;
498498-499499- for i in 0..5 {
500500- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
501501- }
502502-503503- let res = client
504504- .get(format!(
505505- "{}/xrpc/com.atproto.repo.listRecords",
506506- base_url().await
507507- ))
508508- .query(&[
509509- ("repo", did.as_str()),
510510- ("collection", "app.bsky.feed.post"),
511511- ("limit", "2"),
512512- ("reverse", "true"),
513513- ])
514514- .send()
515515- .await
516516- .expect("Failed to list records");
517517-518518- assert_eq!(res.status(), StatusCode::OK);
519519- let body: Value = res.json().await.unwrap();
520520- let records = body["records"].as_array().unwrap();
521521- let first_rkeys: Vec<&str> = records
522522- .iter()
523523- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
524524- .collect();
525525-526526- assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
527527-528528- if let Some(cursor) = body["cursor"].as_str() {
529529- let res2 = client
530530- .get(format!(
531531- "{}/xrpc/com.atproto.repo.listRecords",
532532- base_url().await
533533- ))
534534- .query(&[
535535- ("repo", did.as_str()),
536536- ("collection", "app.bsky.feed.post"),
537537- ("limit", "2"),
538538- ("reverse", "true"),
539539- ("cursor", cursor),
540540- ])
541541- .send()
542542- .await
543543- .expect("Failed to list records with cursor");
544544-545545- let body2: Value = res2.json().await.unwrap();
546546- let records2 = body2["records"].as_array().unwrap();
547547- let second_rkeys: Vec<&str> = records2
548548- .iter()
549549- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
550550- .collect();
551551-552552- assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
553553- }
554554-}