···0001SERVER_HOST=127.0.0.1
2SERVER_PORT=3000
30000004DATABASE_URL=postgres://postgres:postgres@localhost:5432/pds
56-S3_ENDPOINT=http://objsto:9000
000000007AWS_REGION=us-east-1
8S3_BUCKET=pds-blobs
9AWS_ACCESS_KEY_ID=minioadmin
10AWS_SECRET_ACCESS_KEY=minioadmin
1112-# The public-facing hostname of the PDS
13-PDS_HOSTNAME=localhost:3000
14-PLC_URL=plc.directory
000000000001516-# A comma-separated list of relay URLs to notify via requestCrawl when we have updates.
17-# e.g., CRAWLERS=https://bsky.network
18-CRAWLERS=
1920-# Notification Service Configuration
21-# At least one notification channel should be configured for user notifications to work.
000000000000000000000000000000000000002223# Email notifications (via sendmail/msmtp)
24# MAIL_FROM_ADDRESS=noreply@example.com
···34# Signal notifications (via signal-cli)
35# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
36# SIGNAL_SENDER_NUMBER=+1234567890
0000000000000000000000000000000000000003738CARGO_MOMMYS_LITTLE=mister
39CARGO_MOMMYS_PRONOUNS=his
···1+# =============================================================================
2+# Server
3+# =============================================================================
4SERVER_HOST=127.0.0.1
5SERVER_PORT=3000
67+# The public-facing hostname of the PDS (used in DID documents, JWTs, etc.)
8+PDS_HOSTNAME=localhost:3000
9+10+# =============================================================================
11+# Database
12+# =============================================================================
13DATABASE_URL=postgres://postgres:postgres@localhost:5432/pds
1415+# Connection pool settings (defaults are good for most deployments)
16+# DATABASE_MAX_CONNECTIONS=100
17+# DATABASE_MIN_CONNECTIONS=10
18+# DATABASE_ACQUIRE_TIMEOUT_SECS=30
19+20+# =============================================================================
21+# Blob Storage (S3-compatible)
22+# =============================================================================
23+S3_ENDPOINT=http://localhost:9000
24AWS_REGION=us-east-1
25S3_BUCKET=pds-blobs
26AWS_ACCESS_KEY_ID=minioadmin
27AWS_SECRET_ACCESS_KEY=minioadmin
2829+# =============================================================================
30+# Valkey (for caching and distributed rate limiting)
31+# =============================================================================
32+# If not set, falls back to in-memory caching (single-node only)
33+# VALKEY_URL=redis://localhost:6379
34+35+# =============================================================================
36+# Security Secrets
37+# =============================================================================
38+# These MUST be set in production (minimum 32 characters each)
39+# In development, set BSPDS_ALLOW_INSECURE_SECRETS=1 to use defaults
40+41+# Server-wide secret for OAuth token signing (HS256)
42+# JWT_SECRET=your-secure-random-string-at-least-32-chars
4344+# Secret for DPoP proof validation
45+# DPOP_SECRET=your-secure-random-string-at-least-32-chars
04647+# Key for encrypting user signing keys at rest (AES-256-GCM)
48+# MASTER_KEY=your-secure-random-string-at-least-32-chars
49+50+# Set this ONLY in development to allow default/weak secrets
51+# BSPDS_ALLOW_INSECURE_SECRETS=1
52+53+# =============================================================================
54+# PLC Directory
55+# =============================================================================
56+# PLC_DIRECTORY_URL=https://plc.directory
57+# PLC_TIMEOUT_SECS=10
58+# PLC_CONNECT_TIMEOUT_SECS=5
59+60+# Optional: rotation key for PLC operations (defaults to user's key)
61+# PLC_ROTATION_KEY=did:key:...
62+63+# =============================================================================
64+# Federation
65+# =============================================================================
66+# Appview URL for proxying app.bsky.* requests
67+# APPVIEW_URL=https://api.bsky.app
68+69+# Comma-separated list of relay URLs to notify via requestCrawl
70+# CRAWLERS=https://bsky.network
71+72+# =============================================================================
73+# Firehose (subscribeRepos WebSocket)
74+# =============================================================================
75+# Buffer size for firehose broadcast channel
76+# FIREHOSE_BUFFER_SIZE=10000
77+78+# Disconnect slow consumers after this many events of lag
79+# FIREHOSE_MAX_LAG=5000
80+81+# =============================================================================
82+# Notification Service
83+# =============================================================================
84+# Queue processing settings
85+# NOTIFICATION_BATCH_SIZE=100
86+# NOTIFICATION_POLL_INTERVAL_MS=1000
8788# Email notifications (via sendmail/msmtp)
89# MAIL_FROM_ADDRESS=noreply@example.com
···99# Signal notifications (via signal-cli)
100# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
101# SIGNAL_SENDER_NUMBER=+1234567890
102+103+# =============================================================================
104+# Repository Import
105+# =============================================================================
106+# Set to "true" to accept repository imports
107+# ACCEPTING_REPO_IMPORTS=false
108+109+# Maximum import size in bytes (default: 50MB)
110+# MAX_IMPORT_SIZE=52428800
111+112+# Maximum blocks per import (default: 100000)
113+# MAX_IMPORT_BLOCKS=100000
114+115+# Skip verification during import (testing only)
116+# SKIP_IMPORT_VERIFICATION=false
117+118+# =============================================================================
119+# Account Registration
120+# =============================================================================
121+# Require invite codes for registration
122+# INVITE_CODE_REQUIRED=false
123+124+# Comma-separated list of available user domains
125+# AVAILABLE_USER_DOMAINS=example.com
126+127+# =============================================================================
128+# Rate Limiting
129+# =============================================================================
130+# Disable all rate limiting (testing only, NEVER in production)
131+# DISABLE_RATE_LIMITING=1
132+133+# =============================================================================
134+# Miscellaneous
135+# =============================================================================
136+# Allow HTTP for proxy requests (development only)
137+# ALLOW_HTTP_PROXY=1
138+139+# Custom frontend directory (defaults to ./frontend/dist)
140+# FRONTEND_DIR=/path/to/frontend/dist
141142CARGO_MOMMYS_LITTLE=mister
143CARGO_MOMMYS_PRONOUNS=his
···1-# BSPDS, a Personal Data Server
2-3-A production-grade Personal Data Server (PDS) implementation for the AT Protocol.
45-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.
67## Features
89-- Full AT Protocol support, all `com.atproto.*` endpoints implemented
10-- OAuth 2.1 Provider. PKCE, DPoP, Pushed Authorization Requests
11-- PostgreSQL, prod-ready database backend
12-- S3-compatible object storage for blobs; works with AWS S3, UpCloud object storage, self-hosted MinIO, etc.
13-- WebSocket `subscribeRepos` endpoint for real-time sync
14-- Crawler notifications via `requestCrawl`
15-- Multi-channel notifications: email, discord, telegram, signal
16-- Per-IP rate limiting on sensitive endpoints
17- Built-in web UI for account management
18-19-## Running Locally
20-21-Requires Rust installed locally.
22-23-Run PostgreSQL and S3-compatible object store (e.g., with podman/docker):
24-25-```bash
26-podman compose up db objsto -d
27-```
2829-Run the PDS:
3031```bash
0032just run
33```
3435## Configuration
3637-### Required
38-39-| Variable | Description |
40-|----------|-------------|
41-| `DATABASE_URL` | PostgreSQL connection string |
42-| `S3_BUCKET` | Blob storage bucket name |
43-| `S3_ENDPOINT` | S3 endpoint URL (for MinIO, etc.) |
44-| `AWS_ACCESS_KEY_ID` | S3 credentials |
45-| `AWS_SECRET_ACCESS_KEY` | S3 credentials |
46-| `AWS_REGION` | S3 region |
47-| `PDS_HOSTNAME` | Public hostname of this PDS |
48-| `JWT_SECRET` | Secret for OAuth token signing (HS256) |
49-| `KEY_ENCRYPTION_KEY` | Key for encrypting user signing keys (AES-256-GCM) |
50-51-### Optional
52-53-| Variable | Description |
54-|----------|-------------|
55-| `APPVIEW_URL` | Appview URL to proxy unimplemented endpoints to |
56-| `CRAWLERS` | Comma-separated list of relay URLs to notify via `requestCrawl` |
57-58-### Notifications
59-60-At least one channel should be configured for user notifications (password reset, email verification, etc.):
61-62-| Variable | Description |
63-|----------|-------------|
64-| `MAIL_FROM_ADDRESS` | Email sender address (enables email via sendmail) |
65-| `MAIL_FROM_NAME` | Email sender name (default: "BSPDS") |
66-| `SENDMAIL_PATH` | Path to sendmail binary (default: /usr/sbin/sendmail) |
67-| `DISCORD_WEBHOOK_URL` | Discord webhook URL for notifications |
68-| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
69-| `SIGNAL_CLI_PATH` | Path to signal-cli binary |
70-| `SIGNAL_SENDER_NUMBER` | Signal sender phone number (+1234567890 format) |
7172## Development
7374-```bash
75-just # Show available commands
76-just test # Run tests (auto-starts postgres/minio, runs nextest)
77-just lint # Clippy + fmt check
78-just db-reset # Drop and recreate local database
79-```
80-81-## Web UI
82-83-BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
84-85-- Sign in and register new accounts
86-- Manage app passwords
87-- View and create invite codes
88-- Update email and handle
89-- Configure notification preferences
90-- Browse their repository data
91-92-The frontend is built with svelte and deno, and is served directly by the PDS.
9394```bash
95-just frontend-dev # Run frontend dev server
96-just frontend-build # Build for production
97-just frontend-test # Run frontend tests
98-```
99-100-## Project Structure
101-102-```
103-src/
104- main.rs Server entrypoint
105- lib.rs Router setup
106- state.rs AppState (db pool, stores, rate limiters, circuit breakers)
107- api/ XRPC handlers organized by namespace
108- auth/ JWT authentication (ES256K per-user keys)
109- oauth/ OAuth 2.1 provider (HS256 server-wide)
110- repo/ PostgreSQL block store
111- storage/ S3 blob storage
112- sync/ Firehose, CAR export, crawler notifications
113- notifications/ Multi-channel notification service
114- plc/ PLC directory client
115- circuit_breaker/ Circuit breaker for external services
116- rate_limit/ Per-IP rate limiting
117-frontend/ Svelte web UI (deno)
118-tests/ Integration tests
119-migrations/ SQLx migrations
120```
121122## License
···1+# BSPDS
0023+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.
45## Features
67+- Full AT Protocol support (`com.atproto.*` endpoints)
8+- OAuth 2.1 provider (PKCE, DPoP, PAR)
9+- WebSocket firehose (`subscribeRepos`)
10+- Multi-channel notifications (email, discord, telegram, signal)
000011- Built-in web UI for account management
12+- Per-IP rate limiting
0000000001314+## Quick Start
1516```bash
17+cp .env.example .env
18+podman compose up -d
19just run
20```
2122## Configuration
2324+See `.env.example` for all configuration options.
0000000000000000000000000000000002526## Development
2728+Run `just` to see available commands.
0000000000000000002930```bash
31+just test # run tests
32+just lint # clippy + fmt
0000000000000000000000033```
3435## License
+2-2
TODO.md
···201- [x] DID Cache
202 - [x] Implement caching layer for DID resolution (valkey).
203 - [x] Handle cache invalidation/expiry.
204- - [x] Graceful fallback to no-cache when Valkey unavailable.
205- [x] Crawlers Service
206 - [x] Implement `Crawlers` service (debounce notifications to relays).
207 - [x] 20-minute notification debounce.
···237 - [x] Per-IP rate limiting on OAuth revoke/introspect (30/min).
238 - [x] Per-IP rate limiting on createAppPassword (10/min).
239 - [x] Per-IP rate limiting on email endpoints (5/hour).
240- - [x] Distributed rate limiting via Valkey/Redis (with in-memory fallback).
241- [x] Circuit Breakers
242 - [x] PLC directory circuit breaker (5 failures → open, 60s timeout).
243 - [x] Relay notification circuit breaker (10 failures → open, 30s timeout).
···201- [x] DID Cache
202 - [x] Implement caching layer for DID resolution (valkey).
203 - [x] Handle cache invalidation/expiry.
204+ - [x] Graceful fallback to no-cache when valkey unavailable.
205- [x] Crawlers Service
206 - [x] Implement `Crawlers` service (debounce notifications to relays).
207 - [x] 20-minute notification debounce.
···237 - [x] Per-IP rate limiting on OAuth revoke/introspect (30/min).
238 - [x] Per-IP rate limiting on createAppPassword (10/min).
239 - [x] Per-IP rate limiting on email endpoints (5/hour).
240+ - [x] Distributed rate limiting via valkey (with in-memory fallback).
241- [x] Circuit Breakers
242 - [x] PLC directory circuit breaker (5 failures → open, 60s timeout).
243 - [x] Relay notification circuit breaker (10 failures → open, 30s timeout).
···1+CREATE INDEX IF NOT EXISTS idx_records_repo_collection
2+ ON records(repo_id, collection);
3+4+CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created
5+ ON records(repo_id, collection, created_at DESC);
6+7+CREATE INDEX IF NOT EXISTS idx_users_email
8+ ON users(email)
9+ WHERE email IS NOT NULL;
10+11+CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user
12+ ON blobs(created_by_user, created_at DESC);
13+14+CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq
15+ ON repo_seq(did, seq DESC);
16+17+CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
18+ ON app_passwords(user_id);
19+20+CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by
21+ ON invite_codes(created_by_user);
···10 SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
11};
12use chrono::{Duration, Utc};
13-use common::{base_url, client, create_account_and_login};
14use k256::SecretKey;
15use k256::ecdsa::{SigningKey, Signature, signature::Signer};
16use rand::rngs::OsRng;
···906907 assert_eq!(create_res.status(), StatusCode::OK);
908 let account: Value = create_res.json().await.unwrap();
909- let refresh_jwt = account["refreshJwt"].as_str().unwrap().to_string();
000000000000000000000000000000910911 let first_refresh = http_client
912 .post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
···980 let url = base_url().await;
981 let http_client = client();
982983- let ts = Utc::now().timestamp_millis();
984- let handle = format!("del-sess-{}", ts);
985- let email = format!("del-sess-{}@example.com", ts);
986- let password = "test-password-123";
987-988- let create_res = http_client
989- .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
990- .json(&json!({
991- "handle": handle,
992- "email": email,
993- "password": password
994- }))
995- .send()
996- .await
997- .unwrap();
998-999- let account: Value = create_res.json().await.unwrap();
1000- let access_jwt = account["accessJwt"].as_str().unwrap().to_string();
10011002 let get_res = http_client
1003 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
···1029 let url = base_url().await;
1030 let http_client = client();
10311032- let ts = Utc::now().timestamp_millis();
1033- let handle = format!("deact-jwt-{}", ts);
1034- let email = format!("deact-jwt-{}@example.com", ts);
1035- let password = "test-password-123";
1036-1037- let create_res = http_client
1038- .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
1039- .json(&json!({
1040- "handle": handle,
1041- "email": email,
1042- "password": password
1043- }))
1044- .send()
1045- .await
1046- .unwrap();
1047-1048- let account: Value = create_res.json().await.unwrap();
1049- let access_jwt = account["accessJwt"].as_str().unwrap().to_string();
10501051 let deact_res = http_client
1052 .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
···10 SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
11};
12use chrono::{Duration, Utc};
13+use common::{base_url, client, create_account_and_login, get_db_connection_string};
14use k256::SecretKey;
15use k256::ecdsa::{SigningKey, Signature, signature::Signer};
16use rand::rngs::OsRng;
···906907 assert_eq!(create_res.status(), StatusCode::OK);
908 let account: Value = create_res.json().await.unwrap();
909+ let did = account["did"].as_str().unwrap();
910+911+ let conn_str = get_db_connection_string().await;
912+ let pool = sqlx::postgres::PgPoolOptions::new()
913+ .max_connections(2)
914+ .connect(&conn_str)
915+ .await
916+ .expect("Failed to connect to test database");
917+918+ let verification_code: String = sqlx::query_scalar!(
919+ "SELECT email_confirmation_code FROM users WHERE did = $1",
920+ did
921+ )
922+ .fetch_one(&pool)
923+ .await
924+ .expect("Failed to get verification code")
925+ .expect("No verification code found");
926+927+ let confirm_res = http_client
928+ .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
929+ .json(&json!({
930+ "did": did,
931+ "verificationCode": verification_code
932+ }))
933+ .send()
934+ .await
935+ .unwrap();
936+937+ assert_eq!(confirm_res.status(), StatusCode::OK);
938+ let confirmed: Value = confirm_res.json().await.unwrap();
939+ let refresh_jwt = confirmed["refreshJwt"].as_str().unwrap().to_string();
940941 let first_refresh = http_client
942 .post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
···1010 let url = base_url().await;
1011 let http_client = client();
10121013+ let (access_jwt, _did) = create_account_and_login(&http_client).await;
0000000000000000010141015 let get_res = http_client
1016 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
···1042 let url = base_url().await;
1043 let http_client = client();
10441045+ let (access_jwt, _did) = create_account_and_login(&http_client).await;
0000000000000000010461047 let deact_res = http_client
1048 .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
+545-52
tests/lifecycle_record.rs
···664}
665666#[tokio::test]
667-async fn test_list_records_pagination() {
668- let client = client();
669- let (did, jwt) = setup_new_user("list-pagination").await;
670-671- for i in 0..5 {
672- tokio::time::sleep(Duration::from_millis(50)).await;
673- create_post(&client, &did, &jwt, &format!("Post number {}", i)).await;
674- }
675-676- let list_res = client
677- .get(format!(
678- "{}/xrpc/com.atproto.repo.listRecords",
679- base_url().await
680- ))
681- .query(&[
682- ("repo", did.as_str()),
683- ("collection", "app.bsky.feed.post"),
684- ("limit", "2"),
685- ])
686- .send()
687- .await
688- .expect("Failed to list records");
689-690- assert_eq!(list_res.status(), StatusCode::OK);
691- let list_body: Value = list_res.json().await.unwrap();
692- let records = list_body["records"].as_array().unwrap();
693- assert_eq!(records.len(), 2, "Should return 2 records with limit=2");
694-695- if let Some(cursor) = list_body["cursor"].as_str() {
696- let list_page2_res = client
697- .get(format!(
698- "{}/xrpc/com.atproto.repo.listRecords",
699- base_url().await
700- ))
701- .query(&[
702- ("repo", did.as_str()),
703- ("collection", "app.bsky.feed.post"),
704- ("limit", "2"),
705- ("cursor", cursor),
706- ])
707- .send()
708- .await
709- .expect("Failed to list records page 2");
710-711- assert_eq!(list_page2_res.status(), StatusCode::OK);
712- let page2_body: Value = list_page2_res.json().await.unwrap();
713- let page2_records = page2_body["records"].as_array().unwrap();
714- assert_eq!(page2_records.len(), 2, "Page 2 should have 2 more records");
715- }
716-}
717-718-#[tokio::test]
719async fn test_apply_writes_batch_lifecycle() {
720 let client = client();
721 let (did, jwt) = setup_new_user("apply-writes-batch").await;
···885 "Batch-deleted post should be gone"
886 );
887}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···664}
665666#[tokio::test]
0000000000000000000000000000000000000000000000000000667async fn test_apply_writes_batch_lifecycle() {
668 let client = client();
669 let (did, jwt) = setup_new_user("apply-writes-batch").await;
···833 "Batch-deleted post should be gone"
834 );
835}
836+837+async fn create_post_with_rkey(
838+ client: &reqwest::Client,
839+ did: &str,
840+ jwt: &str,
841+ rkey: &str,
842+ text: &str,
843+) -> (String, String) {
844+ let payload = json!({
845+ "repo": did,
846+ "collection": "app.bsky.feed.post",
847+ "rkey": rkey,
848+ "record": {
849+ "$type": "app.bsky.feed.post",
850+ "text": text,
851+ "createdAt": Utc::now().to_rfc3339()
852+ }
853+ });
854+855+ let res = client
856+ .post(format!(
857+ "{}/xrpc/com.atproto.repo.putRecord",
858+ base_url().await
859+ ))
860+ .bearer_auth(jwt)
861+ .json(&payload)
862+ .send()
863+ .await
864+ .expect("Failed to create record");
865+866+ assert_eq!(res.status(), StatusCode::OK);
867+ let body: Value = res.json().await.unwrap();
868+ (
869+ body["uri"].as_str().unwrap().to_string(),
870+ body["cid"].as_str().unwrap().to_string(),
871+ )
872+}
873+874+#[tokio::test]
875+async fn test_list_records_default_order() {
876+ let client = client();
877+ let (did, jwt) = setup_new_user("list-default-order").await;
878+879+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
880+ tokio::time::sleep(Duration::from_millis(50)).await;
881+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
882+ tokio::time::sleep(Duration::from_millis(50)).await;
883+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
884+885+ let res = client
886+ .get(format!(
887+ "{}/xrpc/com.atproto.repo.listRecords",
888+ base_url().await
889+ ))
890+ .query(&[
891+ ("repo", did.as_str()),
892+ ("collection", "app.bsky.feed.post"),
893+ ])
894+ .send()
895+ .await
896+ .expect("Failed to list records");
897+898+ assert_eq!(res.status(), StatusCode::OK);
899+ let body: Value = res.json().await.unwrap();
900+ let records = body["records"].as_array().unwrap();
901+902+ assert_eq!(records.len(), 3);
903+ let rkeys: Vec<&str> = records
904+ .iter()
905+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
906+ .collect();
907+908+ assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
909+}
910+911+#[tokio::test]
912+async fn test_list_records_reverse_true() {
913+ let client = client();
914+ let (did, jwt) = setup_new_user("list-reverse").await;
915+916+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
917+ tokio::time::sleep(Duration::from_millis(50)).await;
918+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
919+ tokio::time::sleep(Duration::from_millis(50)).await;
920+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
921+922+ let res = client
923+ .get(format!(
924+ "{}/xrpc/com.atproto.repo.listRecords",
925+ base_url().await
926+ ))
927+ .query(&[
928+ ("repo", did.as_str()),
929+ ("collection", "app.bsky.feed.post"),
930+ ("reverse", "true"),
931+ ])
932+ .send()
933+ .await
934+ .expect("Failed to list records");
935+936+ assert_eq!(res.status(), StatusCode::OK);
937+ let body: Value = res.json().await.unwrap();
938+ let records = body["records"].as_array().unwrap();
939+940+ let rkeys: Vec<&str> = records
941+ .iter()
942+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
943+ .collect();
944+945+ assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
946+}
947+948+#[tokio::test]
949+async fn test_list_records_cursor_pagination() {
950+ let client = client();
951+ let (did, jwt) = setup_new_user("list-cursor").await;
952+953+ for i in 0..5 {
954+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
955+ tokio::time::sleep(Duration::from_millis(50)).await;
956+ }
957+958+ let res = client
959+ .get(format!(
960+ "{}/xrpc/com.atproto.repo.listRecords",
961+ base_url().await
962+ ))
963+ .query(&[
964+ ("repo", did.as_str()),
965+ ("collection", "app.bsky.feed.post"),
966+ ("limit", "2"),
967+ ])
968+ .send()
969+ .await
970+ .expect("Failed to list records");
971+972+ assert_eq!(res.status(), StatusCode::OK);
973+ let body: Value = res.json().await.unwrap();
974+ let records = body["records"].as_array().unwrap();
975+ assert_eq!(records.len(), 2);
976+977+ let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
978+979+ let res2 = client
980+ .get(format!(
981+ "{}/xrpc/com.atproto.repo.listRecords",
982+ base_url().await
983+ ))
984+ .query(&[
985+ ("repo", did.as_str()),
986+ ("collection", "app.bsky.feed.post"),
987+ ("limit", "2"),
988+ ("cursor", cursor),
989+ ])
990+ .send()
991+ .await
992+ .expect("Failed to list records with cursor");
993+994+ assert_eq!(res2.status(), StatusCode::OK);
995+ let body2: Value = res2.json().await.unwrap();
996+ let records2 = body2["records"].as_array().unwrap();
997+ assert_eq!(records2.len(), 2);
998+999+ let all_uris: Vec<&str> = records
1000+ .iter()
1001+ .chain(records2.iter())
1002+ .map(|r| r["uri"].as_str().unwrap())
1003+ .collect();
1004+ let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
1005+ assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
1006+}
1007+1008+#[tokio::test]
1009+async fn test_list_records_rkey_start() {
1010+ let client = client();
1011+ let (did, jwt) = setup_new_user("list-rkey-start").await;
1012+1013+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
1014+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
1015+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
1016+ create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
1017+1018+ let res = client
1019+ .get(format!(
1020+ "{}/xrpc/com.atproto.repo.listRecords",
1021+ base_url().await
1022+ ))
1023+ .query(&[
1024+ ("repo", did.as_str()),
1025+ ("collection", "app.bsky.feed.post"),
1026+ ("rkeyStart", "bbbb"),
1027+ ("reverse", "true"),
1028+ ])
1029+ .send()
1030+ .await
1031+ .expect("Failed to list records");
1032+1033+ assert_eq!(res.status(), StatusCode::OK);
1034+ let body: Value = res.json().await.unwrap();
1035+ let records = body["records"].as_array().unwrap();
1036+1037+ let rkeys: Vec<&str> = records
1038+ .iter()
1039+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1040+ .collect();
1041+1042+ for rkey in &rkeys {
1043+ assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
1044+ }
1045+}
1046+1047+#[tokio::test]
1048+async fn test_list_records_rkey_end() {
1049+ let client = client();
1050+ let (did, jwt) = setup_new_user("list-rkey-end").await;
1051+1052+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
1053+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
1054+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
1055+ create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
1056+1057+ let res = client
1058+ .get(format!(
1059+ "{}/xrpc/com.atproto.repo.listRecords",
1060+ base_url().await
1061+ ))
1062+ .query(&[
1063+ ("repo", did.as_str()),
1064+ ("collection", "app.bsky.feed.post"),
1065+ ("rkeyEnd", "cccc"),
1066+ ("reverse", "true"),
1067+ ])
1068+ .send()
1069+ .await
1070+ .expect("Failed to list records");
1071+1072+ assert_eq!(res.status(), StatusCode::OK);
1073+ let body: Value = res.json().await.unwrap();
1074+ let records = body["records"].as_array().unwrap();
1075+1076+ let rkeys: Vec<&str> = records
1077+ .iter()
1078+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1079+ .collect();
1080+1081+ for rkey in &rkeys {
1082+ assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
1083+ }
1084+}
1085+1086+#[tokio::test]
1087+async fn test_list_records_rkey_range() {
1088+ let client = client();
1089+ let (did, jwt) = setup_new_user("list-rkey-range").await;
1090+1091+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
1092+ create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
1093+ create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
1094+ create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
1095+ create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
1096+1097+ let res = client
1098+ .get(format!(
1099+ "{}/xrpc/com.atproto.repo.listRecords",
1100+ base_url().await
1101+ ))
1102+ .query(&[
1103+ ("repo", did.as_str()),
1104+ ("collection", "app.bsky.feed.post"),
1105+ ("rkeyStart", "bbbb"),
1106+ ("rkeyEnd", "dddd"),
1107+ ("reverse", "true"),
1108+ ])
1109+ .send()
1110+ .await
1111+ .expect("Failed to list records");
1112+1113+ assert_eq!(res.status(), StatusCode::OK);
1114+ let body: Value = res.json().await.unwrap();
1115+ let records = body["records"].as_array().unwrap();
1116+1117+ let rkeys: Vec<&str> = records
1118+ .iter()
1119+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1120+ .collect();
1121+1122+ for rkey in &rkeys {
1123+ assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
1124+ }
1125+ assert!(!rkeys.is_empty(), "Should have at least some records in range");
1126+}
1127+1128+#[tokio::test]
1129+async fn test_list_records_limit_clamping_max() {
1130+ let client = client();
1131+ let (did, jwt) = setup_new_user("list-limit-max").await;
1132+1133+ for i in 0..5 {
1134+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
1135+ }
1136+1137+ let res = client
1138+ .get(format!(
1139+ "{}/xrpc/com.atproto.repo.listRecords",
1140+ base_url().await
1141+ ))
1142+ .query(&[
1143+ ("repo", did.as_str()),
1144+ ("collection", "app.bsky.feed.post"),
1145+ ("limit", "1000"),
1146+ ])
1147+ .send()
1148+ .await
1149+ .expect("Failed to list records");
1150+1151+ assert_eq!(res.status(), StatusCode::OK);
1152+ let body: Value = res.json().await.unwrap();
1153+ let records = body["records"].as_array().unwrap();
1154+ assert!(records.len() <= 100, "Limit should be clamped to max 100");
1155+}
1156+1157+#[tokio::test]
1158+async fn test_list_records_limit_clamping_min() {
1159+ let client = client();
1160+ let (did, jwt) = setup_new_user("list-limit-min").await;
1161+1162+ create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
1163+1164+ let res = client
1165+ .get(format!(
1166+ "{}/xrpc/com.atproto.repo.listRecords",
1167+ base_url().await
1168+ ))
1169+ .query(&[
1170+ ("repo", did.as_str()),
1171+ ("collection", "app.bsky.feed.post"),
1172+ ("limit", "0"),
1173+ ])
1174+ .send()
1175+ .await
1176+ .expect("Failed to list records");
1177+1178+ assert_eq!(res.status(), StatusCode::OK);
1179+ let body: Value = res.json().await.unwrap();
1180+ let records = body["records"].as_array().unwrap();
1181+ assert!(records.len() >= 1, "Limit should be clamped to min 1");
1182+}
1183+1184+#[tokio::test]
1185+async fn test_list_records_empty_collection() {
1186+ let client = client();
1187+ let (did, _jwt) = setup_new_user("list-empty").await;
1188+1189+ let res = client
1190+ .get(format!(
1191+ "{}/xrpc/com.atproto.repo.listRecords",
1192+ base_url().await
1193+ ))
1194+ .query(&[
1195+ ("repo", did.as_str()),
1196+ ("collection", "app.bsky.feed.post"),
1197+ ])
1198+ .send()
1199+ .await
1200+ .expect("Failed to list records");
1201+1202+ assert_eq!(res.status(), StatusCode::OK);
1203+ let body: Value = res.json().await.unwrap();
1204+ let records = body["records"].as_array().unwrap();
1205+ assert!(records.is_empty(), "Empty collection should return empty array");
1206+ assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
1207+}
1208+1209+#[tokio::test]
1210+async fn test_list_records_exact_limit() {
1211+ let client = client();
1212+ let (did, jwt) = setup_new_user("list-exact-limit").await;
1213+1214+ for i in 0..10 {
1215+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
1216+ }
1217+1218+ let res = client
1219+ .get(format!(
1220+ "{}/xrpc/com.atproto.repo.listRecords",
1221+ base_url().await
1222+ ))
1223+ .query(&[
1224+ ("repo", did.as_str()),
1225+ ("collection", "app.bsky.feed.post"),
1226+ ("limit", "5"),
1227+ ])
1228+ .send()
1229+ .await
1230+ .expect("Failed to list records");
1231+1232+ assert_eq!(res.status(), StatusCode::OK);
1233+ let body: Value = res.json().await.unwrap();
1234+ let records = body["records"].as_array().unwrap();
1235+ assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
1236+}
1237+1238+#[tokio::test]
1239+async fn test_list_records_cursor_exhaustion() {
1240+ let client = client();
1241+ let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
1242+1243+ for i in 0..3 {
1244+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
1245+ }
1246+1247+ let res = client
1248+ .get(format!(
1249+ "{}/xrpc/com.atproto.repo.listRecords",
1250+ base_url().await
1251+ ))
1252+ .query(&[
1253+ ("repo", did.as_str()),
1254+ ("collection", "app.bsky.feed.post"),
1255+ ("limit", "10"),
1256+ ])
1257+ .send()
1258+ .await
1259+ .expect("Failed to list records");
1260+1261+ assert_eq!(res.status(), StatusCode::OK);
1262+ let body: Value = res.json().await.unwrap();
1263+ let records = body["records"].as_array().unwrap();
1264+ assert_eq!(records.len(), 3);
1265+}
1266+1267+#[tokio::test]
1268+async fn test_list_records_repo_not_found() {
1269+ let client = client();
1270+1271+ let res = client
1272+ .get(format!(
1273+ "{}/xrpc/com.atproto.repo.listRecords",
1274+ base_url().await
1275+ ))
1276+ .query(&[
1277+ ("repo", "did:plc:nonexistent12345"),
1278+ ("collection", "app.bsky.feed.post"),
1279+ ])
1280+ .send()
1281+ .await
1282+ .expect("Failed to list records");
1283+1284+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
1285+}
1286+1287+#[tokio::test]
1288+async fn test_list_records_includes_cid() {
1289+ let client = client();
1290+ let (did, jwt) = setup_new_user("list-includes-cid").await;
1291+1292+ create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
1293+1294+ let res = client
1295+ .get(format!(
1296+ "{}/xrpc/com.atproto.repo.listRecords",
1297+ base_url().await
1298+ ))
1299+ .query(&[
1300+ ("repo", did.as_str()),
1301+ ("collection", "app.bsky.feed.post"),
1302+ ])
1303+ .send()
1304+ .await
1305+ .expect("Failed to list records");
1306+1307+ assert_eq!(res.status(), StatusCode::OK);
1308+ let body: Value = res.json().await.unwrap();
1309+ let records = body["records"].as_array().unwrap();
1310+1311+ for record in records {
1312+ assert!(record["uri"].is_string(), "Record should have uri");
1313+ assert!(record["cid"].is_string(), "Record should have cid");
1314+ assert!(record["value"].is_object(), "Record should have value");
1315+ let cid = record["cid"].as_str().unwrap();
1316+ assert!(cid.starts_with("bafy"), "CID should be valid");
1317+ }
1318+}
1319+1320+#[tokio::test]
1321+async fn test_list_records_cursor_with_reverse() {
1322+ let client = client();
1323+ let (did, jwt) = setup_new_user("list-cursor-reverse").await;
1324+1325+ for i in 0..5 {
1326+ create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
1327+ }
1328+1329+ let res = client
1330+ .get(format!(
1331+ "{}/xrpc/com.atproto.repo.listRecords",
1332+ base_url().await
1333+ ))
1334+ .query(&[
1335+ ("repo", did.as_str()),
1336+ ("collection", "app.bsky.feed.post"),
1337+ ("limit", "2"),
1338+ ("reverse", "true"),
1339+ ])
1340+ .send()
1341+ .await
1342+ .expect("Failed to list records");
1343+1344+ assert_eq!(res.status(), StatusCode::OK);
1345+ let body: Value = res.json().await.unwrap();
1346+ let records = body["records"].as_array().unwrap();
1347+ let first_rkeys: Vec<&str> = records
1348+ .iter()
1349+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1350+ .collect();
1351+1352+ assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
1353+1354+ if let Some(cursor) = body["cursor"].as_str() {
1355+ let res2 = client
1356+ .get(format!(
1357+ "{}/xrpc/com.atproto.repo.listRecords",
1358+ base_url().await
1359+ ))
1360+ .query(&[
1361+ ("repo", did.as_str()),
1362+ ("collection", "app.bsky.feed.post"),
1363+ ("limit", "2"),
1364+ ("reverse", "true"),
1365+ ("cursor", cursor),
1366+ ])
1367+ .send()
1368+ .await
1369+ .expect("Failed to list records with cursor");
1370+1371+ let body2: Value = res2.json().await.unwrap();
1372+ let records2 = body2["records"].as_array().unwrap();
1373+ let second_rkeys: Vec<&str> = records2
1374+ .iter()
1375+ .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
1376+ .collect();
1377+1378+ assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
1379+ }
1380+}
+18-7
tests/lifecycle_session.rs
···58 .await
59 .expect("Failed to create account");
60 assert_eq!(create_res.status(), StatusCode::OK);
00006162 let login_payload = json!({
63 "identifier": handle,
···128 "email": email,
129 "password": password
130 });
131- client
132 .post(format!(
133 "{}/xrpc/com.atproto.server.createAccount",
134 base_url().await
···137 .send()
138 .await
139 .expect("Failed to create account");
0000140141 let login_payload = json!({
142 "identifier": handle,
···209210 assert_eq!(create_res.status(), StatusCode::OK);
211 let account: Value = create_res.json().await.unwrap();
212- let jwt = account["accessJwt"].as_str().unwrap();
00213214 let create_app_pass_res = client
215 .post(format!(
216 "{}/xrpc/com.atproto.server.createAppPassword",
217 base_url().await
218 ))
219- .bearer_auth(jwt)
220 .json(&json!({ "name": "Test App" }))
221 .send()
222 .await
···232 "{}/xrpc/com.atproto.server.listAppPasswords",
233 base_url().await
234 ))
235- .bearer_auth(jwt)
236 .send()
237 .await
238 .expect("Failed to list app passwords");
···263 "{}/xrpc/com.atproto.server.revokeAppPassword",
264 base_url().await
265 ))
266- .bearer_auth(jwt)
267 .json(&json!({ "name": "Test App" }))
268 .send()
269 .await
···295 "{}/xrpc/com.atproto.server.listAppPasswords",
296 base_url().await
297 ))
298- .bearer_auth(jwt)
299 .send()
300 .await
301 .expect("Failed to list after revoke");
···330 assert_eq!(create_res.status(), StatusCode::OK);
331 let account: Value = create_res.json().await.unwrap();
332 let did = account["did"].as_str().unwrap().to_string();
333- let jwt = account["accessJwt"].as_str().unwrap().to_string();
0334335 let (post_uri, _) = create_post(&client, &did, &jwt, "Post before deactivation").await;
336 let post_rkey = post_uri.split('/').last().unwrap();
···58 .await
59 .expect("Failed to create account");
60 assert_eq!(create_res.status(), StatusCode::OK);
61+ let create_body: Value = create_res.json().await.unwrap();
62+ let did = create_body["did"].as_str().unwrap();
63+64+ let _ = verify_new_account(&client, did).await;
6566 let login_payload = json!({
67 "identifier": handle,
···132 "email": email,
133 "password": password
134 });
135+ let create_res = client
136 .post(format!(
137 "{}/xrpc/com.atproto.server.createAccount",
138 base_url().await
···141 .send()
142 .await
143 .expect("Failed to create account");
144+ let create_body: Value = create_res.json().await.unwrap();
145+ let did = create_body["did"].as_str().unwrap();
146+147+ let _ = verify_new_account(&client, did).await;
148149 let login_payload = json!({
150 "identifier": handle,
···217218 assert_eq!(create_res.status(), StatusCode::OK);
219 let account: Value = create_res.json().await.unwrap();
220+ let did = account["did"].as_str().unwrap();
221+222+ let jwt = verify_new_account(&client, did).await;
223224 let create_app_pass_res = client
225 .post(format!(
226 "{}/xrpc/com.atproto.server.createAppPassword",
227 base_url().await
228 ))
229+ .bearer_auth(&jwt)
230 .json(&json!({ "name": "Test App" }))
231 .send()
232 .await
···242 "{}/xrpc/com.atproto.server.listAppPasswords",
243 base_url().await
244 ))
245+ .bearer_auth(&jwt)
246 .send()
247 .await
248 .expect("Failed to list app passwords");
···273 "{}/xrpc/com.atproto.server.revokeAppPassword",
274 base_url().await
275 ))
276+ .bearer_auth(&jwt)
277 .json(&json!({ "name": "Test App" }))
278 .send()
279 .await
···305 "{}/xrpc/com.atproto.server.listAppPasswords",
306 base_url().await
307 ))
308+ .bearer_auth(&jwt)
309 .send()
310 .await
311 .expect("Failed to list after revoke");
···340 assert_eq!(create_res.status(), StatusCode::OK);
341 let account: Value = create_res.json().await.unwrap();
342 let did = account["did"].as_str().unwrap().to_string();
343+344+ let jwt = verify_new_account(&client, &did).await;
345346 let (post_uri, _) = create_post(&client, &did, &jwt, "Post before deactivation").await;
347 let post_rkey = post_uri.split('/').last().unwrap();
+2-1
tests/lifecycle_social.rs
···441 assert_eq!(create_account_res.status(), StatusCode::OK);
442 let account_body: Value = create_account_res.json().await.unwrap();
443 let did = account_body["did"].as_str().unwrap().to_string();
444- let access_jwt = account_body["accessJwt"].as_str().unwrap().to_string();
0445446 let get_session_res = client
447 .get(format!(
···441 assert_eq!(create_account_res.status(), StatusCode::OK);
442 let account_body: Value = create_account_res.json().await.unwrap();
443 let did = account_body["did"].as_str().unwrap().to_string();
444+445+ let access_jwt = verify_new_account(&client, &did).await;
446447 let get_session_res = client
448 .get(format!(
-554
tests/list_records_pagination.rs
···1-mod common;
2-mod helpers;
3-use common::*;
4-use helpers::*;
5-6-use chrono::Utc;
7-use reqwest::StatusCode;
8-use serde_json::{Value, json};
9-use std::time::Duration;
10-11-async fn create_post_with_rkey(
12- client: &reqwest::Client,
13- did: &str,
14- jwt: &str,
15- rkey: &str,
16- text: &str,
17-) -> (String, String) {
18- let payload = json!({
19- "repo": did,
20- "collection": "app.bsky.feed.post",
21- "rkey": rkey,
22- "record": {
23- "$type": "app.bsky.feed.post",
24- "text": text,
25- "createdAt": Utc::now().to_rfc3339()
26- }
27- });
28-29- let res = client
30- .post(format!(
31- "{}/xrpc/com.atproto.repo.putRecord",
32- base_url().await
33- ))
34- .bearer_auth(jwt)
35- .json(&payload)
36- .send()
37- .await
38- .expect("Failed to create record");
39-40- assert_eq!(res.status(), StatusCode::OK);
41- let body: Value = res.json().await.unwrap();
42- (
43- body["uri"].as_str().unwrap().to_string(),
44- body["cid"].as_str().unwrap().to_string(),
45- )
46-}
47-48-#[tokio::test]
49-async fn test_list_records_default_order() {
50- let client = client();
51- let (did, jwt) = setup_new_user("list-default-order").await;
52-53- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
54- tokio::time::sleep(Duration::from_millis(50)).await;
55- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
56- tokio::time::sleep(Duration::from_millis(50)).await;
57- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
58-59- let res = client
60- .get(format!(
61- "{}/xrpc/com.atproto.repo.listRecords",
62- base_url().await
63- ))
64- .query(&[
65- ("repo", did.as_str()),
66- ("collection", "app.bsky.feed.post"),
67- ])
68- .send()
69- .await
70- .expect("Failed to list records");
71-72- assert_eq!(res.status(), StatusCode::OK);
73- let body: Value = res.json().await.unwrap();
74- let records = body["records"].as_array().unwrap();
75-76- assert_eq!(records.len(), 3);
77- let rkeys: Vec<&str> = records
78- .iter()
79- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
80- .collect();
81-82- assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
83-}
84-85-#[tokio::test]
86-async fn test_list_records_reverse_true() {
87- let client = client();
88- let (did, jwt) = setup_new_user("list-reverse").await;
89-90- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
91- tokio::time::sleep(Duration::from_millis(50)).await;
92- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
93- tokio::time::sleep(Duration::from_millis(50)).await;
94- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
95-96- let res = client
97- .get(format!(
98- "{}/xrpc/com.atproto.repo.listRecords",
99- base_url().await
100- ))
101- .query(&[
102- ("repo", did.as_str()),
103- ("collection", "app.bsky.feed.post"),
104- ("reverse", "true"),
105- ])
106- .send()
107- .await
108- .expect("Failed to list records");
109-110- assert_eq!(res.status(), StatusCode::OK);
111- let body: Value = res.json().await.unwrap();
112- let records = body["records"].as_array().unwrap();
113-114- let rkeys: Vec<&str> = records
115- .iter()
116- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
117- .collect();
118-119- assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
120-}
121-122-#[tokio::test]
123-async fn test_list_records_cursor_pagination() {
124- let client = client();
125- let (did, jwt) = setup_new_user("list-cursor").await;
126-127- for i in 0..5 {
128- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
129- tokio::time::sleep(Duration::from_millis(50)).await;
130- }
131-132- let res = client
133- .get(format!(
134- "{}/xrpc/com.atproto.repo.listRecords",
135- base_url().await
136- ))
137- .query(&[
138- ("repo", did.as_str()),
139- ("collection", "app.bsky.feed.post"),
140- ("limit", "2"),
141- ])
142- .send()
143- .await
144- .expect("Failed to list records");
145-146- assert_eq!(res.status(), StatusCode::OK);
147- let body: Value = res.json().await.unwrap();
148- let records = body["records"].as_array().unwrap();
149- assert_eq!(records.len(), 2);
150-151- let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
152-153- let res2 = client
154- .get(format!(
155- "{}/xrpc/com.atproto.repo.listRecords",
156- base_url().await
157- ))
158- .query(&[
159- ("repo", did.as_str()),
160- ("collection", "app.bsky.feed.post"),
161- ("limit", "2"),
162- ("cursor", cursor),
163- ])
164- .send()
165- .await
166- .expect("Failed to list records with cursor");
167-168- assert_eq!(res2.status(), StatusCode::OK);
169- let body2: Value = res2.json().await.unwrap();
170- let records2 = body2["records"].as_array().unwrap();
171- assert_eq!(records2.len(), 2);
172-173- let all_uris: Vec<&str> = records
174- .iter()
175- .chain(records2.iter())
176- .map(|r| r["uri"].as_str().unwrap())
177- .collect();
178- let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
179- assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
180-}
181-182-#[tokio::test]
183-async fn test_list_records_rkey_start() {
184- let client = client();
185- let (did, jwt) = setup_new_user("list-rkey-start").await;
186-187- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
188- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
189- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
190- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
191-192- let res = client
193- .get(format!(
194- "{}/xrpc/com.atproto.repo.listRecords",
195- base_url().await
196- ))
197- .query(&[
198- ("repo", did.as_str()),
199- ("collection", "app.bsky.feed.post"),
200- ("rkeyStart", "bbbb"),
201- ("reverse", "true"),
202- ])
203- .send()
204- .await
205- .expect("Failed to list records");
206-207- assert_eq!(res.status(), StatusCode::OK);
208- let body: Value = res.json().await.unwrap();
209- let records = body["records"].as_array().unwrap();
210-211- let rkeys: Vec<&str> = records
212- .iter()
213- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
214- .collect();
215-216- for rkey in &rkeys {
217- assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
218- }
219-}
220-221-#[tokio::test]
222-async fn test_list_records_rkey_end() {
223- let client = client();
224- let (did, jwt) = setup_new_user("list-rkey-end").await;
225-226- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
227- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
228- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
229- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
230-231- let res = client
232- .get(format!(
233- "{}/xrpc/com.atproto.repo.listRecords",
234- base_url().await
235- ))
236- .query(&[
237- ("repo", did.as_str()),
238- ("collection", "app.bsky.feed.post"),
239- ("rkeyEnd", "cccc"),
240- ("reverse", "true"),
241- ])
242- .send()
243- .await
244- .expect("Failed to list records");
245-246- assert_eq!(res.status(), StatusCode::OK);
247- let body: Value = res.json().await.unwrap();
248- let records = body["records"].as_array().unwrap();
249-250- let rkeys: Vec<&str> = records
251- .iter()
252- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
253- .collect();
254-255- for rkey in &rkeys {
256- assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
257- }
258-}
259-260-#[tokio::test]
261-async fn test_list_records_rkey_range() {
262- let client = client();
263- let (did, jwt) = setup_new_user("list-rkey-range").await;
264-265- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
266- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
267- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
268- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
269- create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
270-271- let res = client
272- .get(format!(
273- "{}/xrpc/com.atproto.repo.listRecords",
274- base_url().await
275- ))
276- .query(&[
277- ("repo", did.as_str()),
278- ("collection", "app.bsky.feed.post"),
279- ("rkeyStart", "bbbb"),
280- ("rkeyEnd", "dddd"),
281- ("reverse", "true"),
282- ])
283- .send()
284- .await
285- .expect("Failed to list records");
286-287- assert_eq!(res.status(), StatusCode::OK);
288- let body: Value = res.json().await.unwrap();
289- let records = body["records"].as_array().unwrap();
290-291- let rkeys: Vec<&str> = records
292- .iter()
293- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
294- .collect();
295-296- for rkey in &rkeys {
297- assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
298- }
299- assert!(!rkeys.is_empty(), "Should have at least some records in range");
300-}
301-302-#[tokio::test]
303-async fn test_list_records_limit_clamping_max() {
304- let client = client();
305- let (did, jwt) = setup_new_user("list-limit-max").await;
306-307- for i in 0..5 {
308- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
309- }
310-311- let res = client
312- .get(format!(
313- "{}/xrpc/com.atproto.repo.listRecords",
314- base_url().await
315- ))
316- .query(&[
317- ("repo", did.as_str()),
318- ("collection", "app.bsky.feed.post"),
319- ("limit", "1000"),
320- ])
321- .send()
322- .await
323- .expect("Failed to list records");
324-325- assert_eq!(res.status(), StatusCode::OK);
326- let body: Value = res.json().await.unwrap();
327- let records = body["records"].as_array().unwrap();
328- assert!(records.len() <= 100, "Limit should be clamped to max 100");
329-}
330-331-#[tokio::test]
332-async fn test_list_records_limit_clamping_min() {
333- let client = client();
334- let (did, jwt) = setup_new_user("list-limit-min").await;
335-336- create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
337-338- let res = client
339- .get(format!(
340- "{}/xrpc/com.atproto.repo.listRecords",
341- base_url().await
342- ))
343- .query(&[
344- ("repo", did.as_str()),
345- ("collection", "app.bsky.feed.post"),
346- ("limit", "0"),
347- ])
348- .send()
349- .await
350- .expect("Failed to list records");
351-352- assert_eq!(res.status(), StatusCode::OK);
353- let body: Value = res.json().await.unwrap();
354- let records = body["records"].as_array().unwrap();
355- assert!(records.len() >= 1, "Limit should be clamped to min 1");
356-}
357-358-#[tokio::test]
359-async fn test_list_records_empty_collection() {
360- let client = client();
361- let (did, _jwt) = setup_new_user("list-empty").await;
362-363- let res = client
364- .get(format!(
365- "{}/xrpc/com.atproto.repo.listRecords",
366- base_url().await
367- ))
368- .query(&[
369- ("repo", did.as_str()),
370- ("collection", "app.bsky.feed.post"),
371- ])
372- .send()
373- .await
374- .expect("Failed to list records");
375-376- assert_eq!(res.status(), StatusCode::OK);
377- let body: Value = res.json().await.unwrap();
378- let records = body["records"].as_array().unwrap();
379- assert!(records.is_empty(), "Empty collection should return empty array");
380- assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
381-}
382-383-#[tokio::test]
384-async fn test_list_records_exact_limit() {
385- let client = client();
386- let (did, jwt) = setup_new_user("list-exact-limit").await;
387-388- for i in 0..10 {
389- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
390- }
391-392- let res = client
393- .get(format!(
394- "{}/xrpc/com.atproto.repo.listRecords",
395- base_url().await
396- ))
397- .query(&[
398- ("repo", did.as_str()),
399- ("collection", "app.bsky.feed.post"),
400- ("limit", "5"),
401- ])
402- .send()
403- .await
404- .expect("Failed to list records");
405-406- assert_eq!(res.status(), StatusCode::OK);
407- let body: Value = res.json().await.unwrap();
408- let records = body["records"].as_array().unwrap();
409- assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
410-}
411-412-#[tokio::test]
413-async fn test_list_records_cursor_exhaustion() {
414- let client = client();
415- let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
416-417- for i in 0..3 {
418- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
419- }
420-421- let res = client
422- .get(format!(
423- "{}/xrpc/com.atproto.repo.listRecords",
424- base_url().await
425- ))
426- .query(&[
427- ("repo", did.as_str()),
428- ("collection", "app.bsky.feed.post"),
429- ("limit", "10"),
430- ])
431- .send()
432- .await
433- .expect("Failed to list records");
434-435- assert_eq!(res.status(), StatusCode::OK);
436- let body: Value = res.json().await.unwrap();
437- let records = body["records"].as_array().unwrap();
438- assert_eq!(records.len(), 3);
439-}
440-441-#[tokio::test]
442-async fn test_list_records_repo_not_found() {
443- let client = client();
444-445- let res = client
446- .get(format!(
447- "{}/xrpc/com.atproto.repo.listRecords",
448- base_url().await
449- ))
450- .query(&[
451- ("repo", "did:plc:nonexistent12345"),
452- ("collection", "app.bsky.feed.post"),
453- ])
454- .send()
455- .await
456- .expect("Failed to list records");
457-458- assert_eq!(res.status(), StatusCode::NOT_FOUND);
459-}
460-461-#[tokio::test]
462-async fn test_list_records_includes_cid() {
463- let client = client();
464- let (did, jwt) = setup_new_user("list-includes-cid").await;
465-466- create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
467-468- let res = client
469- .get(format!(
470- "{}/xrpc/com.atproto.repo.listRecords",
471- base_url().await
472- ))
473- .query(&[
474- ("repo", did.as_str()),
475- ("collection", "app.bsky.feed.post"),
476- ])
477- .send()
478- .await
479- .expect("Failed to list records");
480-481- assert_eq!(res.status(), StatusCode::OK);
482- let body: Value = res.json().await.unwrap();
483- let records = body["records"].as_array().unwrap();
484-485- for record in records {
486- assert!(record["uri"].is_string(), "Record should have uri");
487- assert!(record["cid"].is_string(), "Record should have cid");
488- assert!(record["value"].is_object(), "Record should have value");
489- let cid = record["cid"].as_str().unwrap();
490- assert!(cid.starts_with("bafy"), "CID should be valid");
491- }
492-}
493-494-#[tokio::test]
495-async fn test_list_records_cursor_with_reverse() {
496- let client = client();
497- let (did, jwt) = setup_new_user("list-cursor-reverse").await;
498-499- for i in 0..5 {
500- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
501- }
502-503- let res = client
504- .get(format!(
505- "{}/xrpc/com.atproto.repo.listRecords",
506- base_url().await
507- ))
508- .query(&[
509- ("repo", did.as_str()),
510- ("collection", "app.bsky.feed.post"),
511- ("limit", "2"),
512- ("reverse", "true"),
513- ])
514- .send()
515- .await
516- .expect("Failed to list records");
517-518- assert_eq!(res.status(), StatusCode::OK);
519- let body: Value = res.json().await.unwrap();
520- let records = body["records"].as_array().unwrap();
521- let first_rkeys: Vec<&str> = records
522- .iter()
523- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
524- .collect();
525-526- assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
527-528- if let Some(cursor) = body["cursor"].as_str() {
529- let res2 = client
530- .get(format!(
531- "{}/xrpc/com.atproto.repo.listRecords",
532- base_url().await
533- ))
534- .query(&[
535- ("repo", did.as_str()),
536- ("collection", "app.bsky.feed.post"),
537- ("limit", "2"),
538- ("reverse", "true"),
539- ("cursor", cursor),
540- ])
541- .send()
542- .await
543- .expect("Failed to list records with cursor");
544-545- let body2: Value = res2.json().await.unwrap();
546- let records2 = body2["records"].as_array().unwrap();
547- let second_rkeys: Vec<&str> = records2
548- .iter()
549- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
550- .collect();
551-552- assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
553- }
554-}