Merge pull request #27 from pubky/feat/feed

Feat/feed
This commit is contained in:
Nuh
2024-08-26 18:45:29 +03:00
committed by GitHub
11 changed files with 391 additions and 108 deletions

View File

@@ -1,5 +1,6 @@
//! Monotonic unix timestamp in microseconds
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::{
ops::{Add, Sub},
@@ -83,6 +84,12 @@ impl Timestamp {
}
}
impl Default for Timestamp {
fn default() -> Self {
Timestamp::now()
}
}
impl Display for Timestamp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let bytes: [u8; 8] = self.into();
@@ -155,6 +162,26 @@ impl Sub<u64> for &Timestamp {
}
}
impl Serialize for Timestamp {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let bytes = self.to_bytes();
bytes.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Timestamp {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let bytes: [u8; 8] = Deserialize::deserialize(deserializer)?;
Ok(Timestamp(u64::from_be_bytes(bytes)))
}
}
#[cfg(not(target_arch = "wasm32"))]
/// Return the number of microseconds since [SystemTime::UNIX_EPOCH]
fn system_time() -> u64 {

View File

@@ -9,6 +9,8 @@ pub mod tables;
use tables::{Tables, TABLES_COUNT};
pub const MAX_LIST_LIMIT: u16 = 100;
#[derive(Debug, Clone)]
pub struct DB {
pub(crate) env: Env,
@@ -43,7 +45,7 @@ mod tests {
.join(Timestamp::now().to_string())
.join("pubky");
let mut db = DB::open(&storage).unwrap();
let db = DB::open(&storage).unwrap();
let keypair = Keypair::random();
let path = "/pub/foo.txt";

View File

@@ -1,6 +1,6 @@
use heed::{Env, RwTxn};
use crate::database::tables::{blobs, entries, sessions, users};
use crate::database::tables::{blobs, entries, events, sessions, users};
pub fn run(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<()> {
let _: users::UsersTable = env.create_database(wtxn, Some(users::USERS_TABLE))?;
@@ -11,5 +11,7 @@ pub fn run(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<()> {
let _: entries::EntriesTable = env.create_database(wtxn, Some(entries::ENTRIES_TABLE))?;
let _: events::EventsTable = env.create_database(wtxn, Some(events::EVENTS_TABLE))?;
Ok(())
}

View File

@@ -1,5 +1,6 @@
pub mod blobs;
pub mod entries;
pub mod events;
pub mod sessions;
pub mod users;
@@ -8,12 +9,15 @@ use heed::{Env, RwTxn};
use blobs::{BlobsTable, BLOBS_TABLE};
use entries::{EntriesTable, ENTRIES_TABLE};
pub const TABLES_COUNT: u32 = 4;
use self::events::{EventsTable, EVENTS_TABLE};
pub const TABLES_COUNT: u32 = 5;
#[derive(Debug, Clone)]
pub struct Tables {
pub blobs: BlobsTable,
pub entries: EntriesTable,
pub events: EventsTable,
}
impl Tables {
@@ -25,6 +29,9 @@ impl Tables {
entries: env
.open_database(wtxn, Some(ENTRIES_TABLE))?
.expect("Entries table already created"),
events: env
.open_database(wtxn, Some(EVENTS_TABLE))?
.expect("Events table already created"),
})
}
}

View File

@@ -12,7 +12,7 @@ pub const BLOBS_TABLE: &str = "blobs";
impl DB {
pub fn get_blob(
&mut self,
&self,
public_key: &PublicKey,
path: &str,
) -> anyhow::Result<Option<bytes::Bytes>> {

View File

@@ -13,15 +13,15 @@ use pubky_common::{
timestamp::Timestamp,
};
use crate::database::DB;
use crate::database::{DB, MAX_LIST_LIMIT};
use super::events::Event;
/// full_path(pubky/*path) => Entry.
pub type EntriesTable = Database<Str, Bytes>;
pub const ENTRIES_TABLE: &str = "entries";
const MAX_LIST_LIMIT: u16 = 100;
impl DB {
pub fn put_entry(
&mut self,
@@ -56,6 +56,19 @@ impl DB {
.entries
.put(&mut wtxn, &key, &entry.serialize())?;
if path.starts_with("pub/") {
let url = format!("pubky://{key}");
let event = Event::put(&url);
let value = event.serialize();
let key = entry.timestamp.to_string();
self.tables.events.put(&mut wtxn, &key, &value)?;
// TODO: delete older events.
// TODO: move to events.rs
}
wtxn.commit()?;
Ok(())
@@ -74,6 +87,21 @@ impl DB {
let deleted_entry = self.tables.entries.delete(&mut wtxn, &key)?;
// create DELETE event
if path.starts_with("pub/") {
let url = format!("pubky://{key}");
let event = Event::delete(&url);
let value = event.serialize();
let key = Timestamp::now().to_string();
self.tables.events.put(&mut wtxn, &key, &value)?;
// TODO: delete older events.
// TODO: move to events.rs
}
deleted_entry & deleted_blobs
} else {
false
@@ -198,7 +226,7 @@ pub struct Entry {
/// Encoding version
version: usize,
/// Modified at
timestamp: u64,
timestamp: Timestamp,
content_hash: [u8; 32],
content_length: usize,
content_type: String,
@@ -209,10 +237,7 @@ pub struct Entry {
impl Entry {
pub fn new() -> Self {
Self {
timestamp: Timestamp::now().into_inner(),
..Default::default()
}
Default::default()
}
// === Setters ===

View File

@@ -0,0 +1,58 @@
//! Server events (Put and Delete entries)
//!
//! Useful as a realtime sync with Indexers until
//! we implement more self-authenticated merkle data.
use heed::{
types::{Bytes, Str},
Database,
};
use postcard::{from_bytes, to_allocvec};
use serde::{Deserialize, Serialize};
/// Event [Timestamp] base32 => Encoded event.
pub type EventsTable = Database<Str, Bytes>;
pub const EVENTS_TABLE: &str = "events";
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
pub enum Event {
Put(String),
Delete(String),
}
impl Event {
pub fn put(url: &str) -> Self {
Self::Put(url.to_string())
}
pub fn delete(url: &str) -> Self {
Self::Delete(url.to_string())
}
pub fn serialize(&self) -> Vec<u8> {
to_allocvec(self).expect("Session::serialize")
}
pub fn deserialize(bytes: &[u8]) -> core::result::Result<Self, postcard::Error> {
if bytes[0] > 1 {
panic!("Unknown Event version");
}
from_bytes(bytes)
}
pub fn url(&self) -> &str {
match self {
Event::Put(url) => url,
Event::Delete(url) => url,
}
}
pub fn operation(&self) -> &str {
match self {
Event::Put(_) => "PUT",
Event::Delete(_) => "DEL",
}
}
}

View File

@@ -11,6 +11,7 @@ use crate::server::AppState;
use self::pkarr::pkarr_router;
mod auth;
mod feed;
mod pkarr;
mod public;
mod root;
@@ -25,6 +26,7 @@ fn base(state: AppState) -> Router {
.route("/:pubky/*path", put(public::put))
.route("/:pubky/*path", get(public::get))
.route("/:pubky/*path", delete(public::delete))
.route("/events/", get(feed::feed))
.layer(CookieManagerLayer::new())
// TODO: revisit if we enable streaming big payloads
// TODO: maybe add to a separate router (drive router?).

View File

@@ -0,0 +1,71 @@
use std::collections::HashMap;
use axum::{
body::Body,
extract::{Query, State},
http::{header, Response, StatusCode},
response::IntoResponse,
};
use crate::{
database::{tables::events::Event, MAX_LIST_LIMIT},
error::Result,
server::AppState,
};
pub async fn feed(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse> {
let txn = state.db.env.read_txn()?;
let limit = params
.get("limit")
.and_then(|l| l.parse::<u16>().ok())
.unwrap_or(MAX_LIST_LIMIT)
.min(MAX_LIST_LIMIT);
let mut cursor = params
.get("cursor")
.map(|c| c.as_str())
.unwrap_or("0000000000000");
// Guard against bad cursor
if cursor.len() < 13 {
cursor = "0000000000000"
}
let mut result: Vec<String> = vec![];
let mut next_cursor = cursor.to_string();
for _ in 0..limit {
match state
.db
.tables
.events
.get_greater_than(&txn, &next_cursor)?
{
Some((timestamp, event_bytes)) => {
let event = Event::deserialize(event_bytes)?;
let line = format!("{} {}", event.operation(), event.url());
next_cursor = timestamp.to_string();
result.push(line);
}
None => break,
};
}
if !result.is_empty() {
result.push(format!("cursor: {next_cursor}"))
}
txn.commit()?;
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(result.join("\n")))
.unwrap())
}

View File

@@ -62,7 +62,7 @@ pub async fn put(
}
pub async fn get(
State(mut state): State<AppState>,
State(state): State<AppState>,
pubky: Pubky,
path: EntryPath,
Query(params): Query<HashMap<String, String>>,
@@ -96,7 +96,7 @@ pub async fn get(
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(vec.join("\n")))
.unwrap());
}

View File

@@ -100,7 +100,7 @@ mod tests {
use pkarr::{mainline::Testnet, Keypair};
use pubky_homeserver::Homeserver;
use reqwest::StatusCode;
use reqwest::{Method, StatusCode};
#[tokio::test]
async fn put_get_delete() {
@@ -206,25 +206,24 @@ mod tests {
client.signup(&keypair, &server.public_key()).await.unwrap();
let pubky = keypair.public_key();
let urls = vec![
format!("pubky://{}/pub/a.wrong/a.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/a.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!(
"pubky://{}/pub/example.com/cc-nested/z.txt",
keypair.public_key()
),
format!("pubky://{}/pub/example.wrong/a.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/d.txt", keypair.public_key()),
format!("pubky://{}/pub/z.wrong/a.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/a.wrong/a.txt"),
format!("pubky://{pubky}/pub/example.com/a.txt"),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
format!("pubky://{pubky}/pub/example.wrong/a.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
format!("pubky://{pubky}/pub/example.com/d.txt"),
format!("pubky://{pubky}/pub/z.wrong/a.txt"),
];
for url in urls {
client.put(url.as_str(), &[0]).await.unwrap();
}
let url = format!("pubky://{}/pub/example.com/extra", keypair.public_key());
let url = format!("pubky://{pubky}/pub/example.com/extra");
let url = url.as_str();
{
@@ -233,14 +232,11 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/a.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!(
"pubky://{}/pub/example.com/cc-nested/z.txt",
keypair.public_key()
),
format!("pubky://{}/pub/example.com/d.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/a.txt"),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
format!("pubky://{pubky}/pub/example.com/d.txt"),
],
"normal list with no limit or cursor"
);
@@ -252,8 +248,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/a.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/a.txt"),
format!("pubky://{pubky}/pub/example.com/b.txt"),
],
"normal list with limit but no cursor"
);
@@ -272,8 +268,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
],
"normal list with limit and a file cursor"
);
@@ -292,11 +288,8 @@ mod tests {
assert_eq!(
list,
vec![
format!(
"pubky://{}/pub/example.com/cc-nested/z.txt",
keypair.public_key()
),
format!("pubky://{}/pub/example.com/d.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
format!("pubky://{pubky}/pub/example.com/d.txt"),
],
"normal list with limit and a directory cursor"
);
@@ -307,10 +300,7 @@ mod tests {
.list(url)
.unwrap()
.limit(2)
.cursor(&format!(
"pubky://{}/pub/example.com/a.txt",
keypair.public_key()
))
.cursor(&format!("pubky://{pubky}/pub/example.com/a.txt"))
.send()
.await
.unwrap();
@@ -318,8 +308,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
],
"normal list with limit and a full url cursor"
);
@@ -338,8 +328,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
],
"normal list with limit and a leading / cursor"
);
@@ -357,14 +347,11 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/d.txt", keypair.public_key()),
format!(
"pubky://{}/pub/example.com/cc-nested/z.txt",
keypair.public_key()
),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/a.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/d.txt"),
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/a.txt"),
],
"reverse list with no limit or cursor"
);
@@ -383,11 +370,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/d.txt", keypair.public_key()),
format!(
"pubky://{}/pub/example.com/cc-nested/z.txt",
keypair.public_key()
),
format!("pubky://{pubky}/pub/example.com/d.txt"),
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
],
"reverse list with limit but no cursor"
);
@@ -407,11 +391,8 @@ mod tests {
assert_eq!(
list,
vec![
format!(
"pubky://{}/pub/example.com/cc-nested/z.txt",
keypair.public_key()
),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
],
"reverse list with limit and cursor"
);
@@ -429,24 +410,26 @@ mod tests {
client.signup(&keypair, &server.public_key()).await.unwrap();
let pubky = keypair.public_key();
let urls = vec![
format!("pubky://{}/pub/a.com/a.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/a.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/b.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/c.txt", keypair.public_key()),
format!("pubky://{}/pub/example.com/d.txt", keypair.public_key()),
format!("pubky://{}/pub/example.con/d.txt", keypair.public_key()),
format!("pubky://{}/pub/example.con", keypair.public_key()),
format!("pubky://{}/pub/file", keypair.public_key()),
format!("pubky://{}/pub/file2", keypair.public_key()),
format!("pubky://{}/pub/z.com/a.txt", keypair.public_key()),
format!("pubky://{pubky}/pub/a.com/a.txt"),
format!("pubky://{pubky}/pub/example.com/a.txt"),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
format!("pubky://{pubky}/pub/example.com/d.txt"),
format!("pubky://{pubky}/pub/example.con/d.txt"),
format!("pubky://{pubky}/pub/example.con"),
format!("pubky://{pubky}/pub/file"),
format!("pubky://{pubky}/pub/file2"),
format!("pubky://{pubky}/pub/z.com/a.txt"),
];
for url in urls {
client.put(url.as_str(), &[0]).await.unwrap();
}
let url = format!("pubky://{}/pub/", keypair.public_key());
let url = format!("pubky://{pubky}/pub/");
let url = url.as_str();
{
@@ -461,13 +444,13 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/a.com/", keypair.public_key()),
format!("pubky://{}/pub/example.com/", keypair.public_key()),
format!("pubky://{}/pub/example.con", keypair.public_key()),
format!("pubky://{}/pub/example.con/", keypair.public_key()),
format!("pubky://{}/pub/file", keypair.public_key()),
format!("pubky://{}/pub/file2", keypair.public_key()),
format!("pubky://{}/pub/z.com/", keypair.public_key()),
format!("pubky://{pubky}/pub/a.com/"),
format!("pubky://{pubky}/pub/example.com/"),
format!("pubky://{pubky}/pub/example.con"),
format!("pubky://{pubky}/pub/example.con/"),
format!("pubky://{pubky}/pub/file"),
format!("pubky://{pubky}/pub/file2"),
format!("pubky://{pubky}/pub/z.com/"),
],
"normal list shallow"
);
@@ -486,8 +469,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/a.com/", keypair.public_key()),
format!("pubky://{}/pub/example.com/", keypair.public_key()),
format!("pubky://{pubky}/pub/a.com/"),
format!("pubky://{pubky}/pub/example.com/"),
],
"normal list shallow with limit but no cursor"
);
@@ -507,8 +490,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.com/", keypair.public_key()),
format!("pubky://{}/pub/example.con", keypair.public_key()),
format!("pubky://{pubky}/pub/example.com/"),
format!("pubky://{pubky}/pub/example.con"),
],
"normal list shallow with limit and a file cursor"
);
@@ -528,9 +511,9 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.con", keypair.public_key()),
format!("pubky://{}/pub/example.con/", keypair.public_key()),
format!("pubky://{}/pub/file", keypair.public_key()),
format!("pubky://{pubky}/pub/example.con"),
format!("pubky://{pubky}/pub/example.con/"),
format!("pubky://{pubky}/pub/file"),
],
"normal list shallow with limit and a directory cursor"
);
@@ -549,13 +532,13 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/z.com/", keypair.public_key()),
format!("pubky://{}/pub/file2", keypair.public_key()),
format!("pubky://{}/pub/file", keypair.public_key()),
format!("pubky://{}/pub/example.con/", keypair.public_key()),
format!("pubky://{}/pub/example.con", keypair.public_key()),
format!("pubky://{}/pub/example.com/", keypair.public_key()),
format!("pubky://{}/pub/a.com/", keypair.public_key()),
format!("pubky://{pubky}/pub/z.com/"),
format!("pubky://{pubky}/pub/file2"),
format!("pubky://{pubky}/pub/file"),
format!("pubky://{pubky}/pub/example.con/"),
format!("pubky://{pubky}/pub/example.con"),
format!("pubky://{pubky}/pub/example.com/"),
format!("pubky://{pubky}/pub/a.com/"),
],
"reverse list shallow"
);
@@ -575,8 +558,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/z.com/", keypair.public_key()),
format!("pubky://{}/pub/file2", keypair.public_key()),
format!("pubky://{pubky}/pub/z.com/"),
format!("pubky://{pubky}/pub/file2"),
],
"reverse list shallow with limit but no cursor"
);
@@ -597,8 +580,8 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/file", keypair.public_key()),
format!("pubky://{}/pub/example.con/", keypair.public_key()),
format!("pubky://{pubky}/pub/file"),
format!("pubky://{pubky}/pub/example.con/"),
],
"reverse list shallow with limit and a file cursor"
);
@@ -619,11 +602,117 @@ mod tests {
assert_eq!(
list,
vec![
format!("pubky://{}/pub/example.con", keypair.public_key()),
format!("pubky://{}/pub/example.com/", keypair.public_key()),
format!("pubky://{pubky}/pub/example.con"),
format!("pubky://{pubky}/pub/example.com/"),
],
"reverse list shallow with limit and a directory cursor"
);
}
}
#[tokio::test]
async fn list_events() {
let testnet = Testnet::new(10);
let server = Homeserver::start_test(&testnet).await.unwrap();
let client = PubkyClient::test(&testnet);
let keypair = Keypair::random();
client.signup(&keypair, &server.public_key()).await.unwrap();
let pubky = keypair.public_key();
let urls = vec![
format!("pubky://{pubky}/pub/a.com/a.txt"),
format!("pubky://{pubky}/pub/example.com/a.txt"),
format!("pubky://{pubky}/pub/example.com/b.txt"),
format!("pubky://{pubky}/pub/example.com/c.txt"),
format!("pubky://{pubky}/pub/example.com/d.txt"),
format!("pubky://{pubky}/pub/example.con/d.txt"),
format!("pubky://{pubky}/pub/example.con"),
format!("pubky://{pubky}/pub/file"),
format!("pubky://{pubky}/pub/file2"),
format!("pubky://{pubky}/pub/z.com/a.txt"),
];
for url in urls {
client.put(url.as_str(), &[0]).await.unwrap();
client.delete(url.as_str()).await.unwrap();
}
let feed_url = format!("http://localhost:{}/events/", server.port());
let feed_url = feed_url.as_str();
let client = PubkyClient::test(&testnet);
let cursor;
{
let response = client
.request(
Method::GET,
format!("{feed_url}?limit=10").as_str().try_into().unwrap(),
)
.send()
.await
.unwrap();
let text = response.text().await.unwrap();
let lines = text.split('\n').collect::<Vec<_>>();
cursor = lines.last().unwrap().split(" ").last().unwrap().to_string();
assert_eq!(
lines,
vec![
format!("PUT pubky://{pubky}/pub/a.com/a.txt"),
format!("DEL pubky://{pubky}/pub/a.com/a.txt"),
format!("PUT pubky://{pubky}/pub/example.com/a.txt"),
format!("DEL pubky://{pubky}/pub/example.com/a.txt"),
format!("PUT pubky://{pubky}/pub/example.com/b.txt"),
format!("DEL pubky://{pubky}/pub/example.com/b.txt"),
format!("PUT pubky://{pubky}/pub/example.com/c.txt"),
format!("DEL pubky://{pubky}/pub/example.com/c.txt"),
format!("PUT pubky://{pubky}/pub/example.com/d.txt"),
format!("DEL pubky://{pubky}/pub/example.com/d.txt"),
format!("cursor: {cursor}",)
]
);
}
{
let response = client
.request(
Method::GET,
format!("{feed_url}?limit=10&cursor={cursor}")
.as_str()
.try_into()
.unwrap(),
)
.send()
.await
.unwrap();
let text = response.text().await.unwrap();
let lines = text.split('\n').collect::<Vec<_>>();
assert_eq!(
lines,
vec![
format!("PUT pubky://{pubky}/pub/example.con/d.txt"),
format!("DEL pubky://{pubky}/pub/example.con/d.txt"),
format!("PUT pubky://{pubky}/pub/example.con"),
format!("DEL pubky://{pubky}/pub/example.con"),
format!("PUT pubky://{pubky}/pub/file"),
format!("DEL pubky://{pubky}/pub/file"),
format!("PUT pubky://{pubky}/pub/file2"),
format!("DEL pubky://{pubky}/pub/file2"),
format!("PUT pubky://{pubky}/pub/z.com/a.txt"),
format!("DEL pubky://{pubky}/pub/z.com/a.txt"),
lines.last().unwrap().to_string()
]
)
}
}
}