fix: resolve 10 E2E bugs + add desktop Playwright tests (#21)

Co-authored-by: Wes <wesb@block.xyz>
This commit is contained in:
tlongwell-block
2026-03-10 17:44:39 -04:00
committed by GitHub
parent 1be84cc315
commit 1fc9373a74
20 changed files with 994 additions and 126 deletions

View File

@@ -339,7 +339,6 @@ impl AuthService {
/// helpers) in release-mode integration test harnesses. It must **not** be
/// enabled in production relay deployments. Check `sprout-relay/Cargo.toml` to
/// ensure `sprout-auth` is not listed with `features = ["dev"]` in production.
#[cfg(any(test, feature = "dev", debug_assertions))]
pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, AuthError> {
use sha2::{Digest, Sha256};
let seed = format!("sprout-test-key:{username}");

View File

@@ -689,12 +689,15 @@ pub struct UserRecord {
///
/// Uses DISTINCT + LEFT JOIN so a user who is a member of an open channel does not
/// see it twice. Results are ordered stream → forum → dm, then alphabetically by name.
///
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels with
/// that visibility value are returned. `None` returns all accessible channels.
pub async fn get_accessible_channels(
pool: &MySqlPool,
pubkey: &[u8],
visibility_filter: Option<&str>,
) -> Result<Vec<ChannelRecord>> {
let rows = sqlx::query(
r#"
let base = r#"
SELECT DISTINCT c.id, c.name, c.channel_type, c.visibility, c.description, c.canvas,
c.created_by, c.created_at, c.updated_at, c.archived_at, c.deleted_at,
c.nip29_group_id, c.topic_required, c.max_members,
@@ -705,14 +708,22 @@ pub async fn get_accessible_channels(
ON c.id = cm.channel_id AND cm.pubkey = ? AND cm.removed_at IS NULL
WHERE c.deleted_at IS NULL
AND (c.visibility = 'open' OR cm.channel_id IS NOT NULL)
ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name
LIMIT 1000
"#,
)
.bind(pubkey)
.fetch_all(pool)
.await?;
"#;
let sql = if visibility_filter.is_some() {
format!("{base} AND c.visibility = ?\n ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
} else {
format!("{base} ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
};
let query = sqlx::query(&sql).bind(pubkey);
let query = if let Some(vis) = visibility_filter {
query.bind(vis)
} else {
query
};
let rows = query.fetch_all(pool).await?;
rows.into_iter().map(row_to_channel_record).collect()
}

View File

@@ -459,6 +459,51 @@ pub async fn insert_event_with_thread_metadata(
// Only bump reply counts if the metadata row was actually inserted.
if tm_result.rows_affected() > 0 {
if let Some(pid) = meta.parent_event_id {
// Ensure the parent has a thread_metadata row so the UPDATE
// below has something to hit. Root (depth=0) messages don't
// get a row on first insert, so we create a stub here.
let parent_ts = meta
.parent_event_created_at
.unwrap_or(meta.event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(parent_ts)
.bind(pid)
.bind(ch_bytes.as_slice())
.execute(&mut *tx)
.await?;
// Ensure the root also has a row (may differ from parent for nested replies).
if let Some(root_id) = meta.root_event_id {
if root_id != pid {
let root_ts =
meta.root_event_created_at.unwrap_or(meta.event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(root_ts)
.bind(root_id)
.bind(ch_bytes.as_slice())
.execute(&mut *tx)
.await?;
}
}
sqlx::query(
r#"
UPDATE thread_metadata

View File

@@ -320,11 +320,15 @@ impl Db {
/// Returns full channel records for all channels accessible to `pubkey`:
/// open channels plus channels where the user is an active member.
///
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels
/// with that visibility are returned.
pub async fn get_accessible_channels(
&self,
pubkey: &[u8],
visibility_filter: Option<&str>,
) -> Result<Vec<channel::ChannelRecord>> {
channel::get_accessible_channels(&self.pool, pubkey).await
channel::get_accessible_channels(&self.pool, pubkey, visibility_filter).await
}
/// Returns all bot-role members with aggregated channel names.
@@ -555,14 +559,15 @@ impl Db {
user::get_user(&self.pool, pubkey).await
}
/// Update a user's display_name and/or avatar_url.
/// Update a user's display_name, avatar_url, and/or about.
pub async fn update_user_profile(
&self,
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
) -> Result<()> {
user::update_user_profile(&self.pool, pubkey, display_name, avatar_url).await
user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about).await
}
// ── API Tokens ───────────────────────────────────────────────────────────

View File

@@ -26,6 +26,8 @@ pub struct ThreadReply {
pub channel_id: Uuid,
/// Compressed public key of the reply author.
pub pubkey: Vec<u8>,
/// Nostr event tags (JSON array), used to extract effective author.
pub tags: serde_json::Value,
/// Text content of the reply.
pub content: String,
/// Nostr event kind number.
@@ -58,6 +60,8 @@ pub struct TopLevelMessage {
pub event_id: Vec<u8>,
/// Compressed public key of the message author.
pub pubkey: Vec<u8>,
/// Nostr event tags (JSON array), used to extract effective author.
pub tags: serde_json::Value,
/// Text content of the message.
pub content: String,
/// Nostr event kind number.
@@ -147,6 +151,48 @@ pub async fn insert_thread_metadata(
// INSERT IGNORE on a duplicate key returns rows_affected = 0.
if result.rows_affected() > 0 {
if let Some(pid) = parent_event_id {
// Ensure the parent has a thread_metadata row so the UPDATE below
// has something to hit. Root (depth=0) messages don't get a row on
// first insert, so we create a stub here with INSERT IGNORE.
let parent_ts = parent_event_created_at.unwrap_or(event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(parent_ts)
.bind(pid)
.bind(channel_id_bytes.as_slice())
.execute(&mut *tx)
.await?;
// Ensure the root also has a row (may differ from parent for nested replies).
if let Some(root_id) = root_event_id {
if root_id != pid {
let root_ts = root_event_created_at.unwrap_or(event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(root_ts)
.bind(root_id)
.bind(channel_id_bytes.as_slice())
.execute(&mut *tx)
.await?;
}
}
// Increment parent's direct reply count and last_reply_at.
sqlx::query(
r#"
@@ -303,6 +349,7 @@ pub async fn get_thread_replies(
tm.root_event_id,
tm.channel_id,
e.pubkey,
e.tags,
e.content,
e.kind,
tm.depth,
@@ -345,6 +392,7 @@ pub async fn get_thread_replies(
let root_event_id_col: Option<Vec<u8>> = row.try_get("root_event_id")?;
let channel_id_bytes: Vec<u8> = row.try_get("channel_id")?;
let pubkey: Vec<u8> = row.try_get("pubkey")?;
let tags: serde_json::Value = row.try_get("tags")?;
let content: String = row.try_get("content")?;
let kind: i32 = row.try_get("kind")?;
let depth: i32 = row.try_get("depth")?;
@@ -359,6 +407,7 @@ pub async fn get_thread_replies(
root_event_id: root_event_id_col,
channel_id,
pubkey,
tags,
content,
kind,
depth,
@@ -455,6 +504,7 @@ pub async fn get_channel_messages_top_level(
SELECT
e.id AS event_id,
e.pubkey,
e.tags,
e.content,
e.kind,
e.created_at,
@@ -492,6 +542,7 @@ pub async fn get_channel_messages_top_level(
for row in rows {
let event_id: Vec<u8> = row.try_get("event_id")?;
let pubkey: Vec<u8> = row.try_get("pubkey")?;
let tags: serde_json::Value = row.try_get("tags")?;
let content: String = row.try_get("content")?;
let kind: i32 = row.try_get("kind")?;
let created_at: DateTime<Utc> = row.try_get("created_at")?;
@@ -501,6 +552,7 @@ pub async fn get_channel_messages_top_level(
messages.push(TopLevelMessage {
event_id,
pubkey,
tags,
content,
kind,
created_at,

View File

@@ -12,6 +12,8 @@ pub struct UserProfile {
pub display_name: Option<String>,
/// URL of the user's avatar image.
pub avatar_url: Option<String>,
/// Short bio or description provided by the user.
pub about: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05_handle: Option<String>,
}
@@ -33,9 +35,18 @@ pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> {
/// Get a single user record by pubkey.
pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProfile>> {
let row = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
let row = sqlx::query_as::<
_,
(
Vec<u8>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
),
>(
r#"
SELECT pubkey, display_name, avatar_url, nip05_handle
SELECT pubkey, display_name, avatar_url, about, nip05_handle
FROM users
WHERE pubkey = ?
"#,
@@ -45,16 +56,17 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProf
.await?;
Ok(row.map(
|(pubkey, display_name, avatar_url, nip05_handle)| UserProfile {
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
pubkey,
display_name,
avatar_url,
about,
nip05_handle,
},
))
}
/// Update a user's profile fields (display_name, avatar_url).
/// Update a user's profile fields (display_name, avatar_url, about).
/// Only updates fields that are Some — None fields are left unchanged.
/// At least one field must be Some, otherwise returns Ok(()) without touching the DB.
pub async fn update_user_profile(
@@ -62,33 +74,37 @@ pub async fn update_user_profile(
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
) -> Result<()> {
match (display_name, avatar_url) {
(Some(name), Some(url)) => {
sqlx::query(r#"UPDATE users SET display_name = ?, avatar_url = ? WHERE pubkey = ?"#)
.bind(name)
.bind(url)
.bind(pubkey)
.execute(pool)
.await?;
// Build SET clause dynamically to avoid 2^3 match arms.
let mut set_parts: Vec<&str> = Vec::new();
if display_name.is_some() {
set_parts.push("display_name = ?");
}
(Some(name), None) => {
sqlx::query(r#"UPDATE users SET display_name = ? WHERE pubkey = ?"#)
.bind(name)
.bind(pubkey)
.execute(pool)
.await?;
if avatar_url.is_some() {
set_parts.push("avatar_url = ?");
}
(None, Some(url)) => {
sqlx::query(r#"UPDATE users SET avatar_url = ? WHERE pubkey = ?"#)
.bind(url)
.bind(pubkey)
.execute(pool)
.await?;
if about.is_some() {
set_parts.push("about = ?");
}
(None, None) => {
if set_parts.is_empty() {
// Nothing to update — caller should have validated at least one field.
return Ok(());
}
let sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", "));
let mut query = sqlx::query(&sql);
if let Some(name) = display_name {
query = query.bind(name);
}
if let Some(url) = avatar_url {
query = query.bind(url);
}
if let Some(bio) = about {
query = query.bind(bio);
}
query = query.bind(pubkey);
query.execute(pool).await?;
Ok(())
}

View File

@@ -367,6 +367,9 @@ pub struct SetProfileParams {
/// URL of the agent's avatar image.
#[serde(default)]
pub avatar_url: Option<String>,
/// Short bio or description.
#[serde(default)]
pub about: Option<String>,
}
/// Parameters for the `get_feed` tool.
@@ -444,49 +447,24 @@ impl SproutMcpServer {
);
}
// If a parent_event_id is provided, route through REST (thread reply).
if let Some(ref parent_id) = p.parent_event_id {
let body = serde_json::json!({
// Route all messages through REST — avoids WebSocket timeout (~5 min).
// The relay determines kind from channel_type; parent_event_id is optional.
let mut body = serde_json::json!({
"content": p.content,
"parent_event_id": parent_id,
});
return match self
if let Some(ref parent_id) = p.parent_event_id {
body["parent_event_id"] = serde_json::Value::String(parent_id.clone());
}
if let Some(kind) = p.kind {
body["kind"] = serde_json::json!(kind);
}
match self
.client
.post(&format!("/api/channels/{}/messages", p.channel_id), &body)
.await
{
Ok(b) => b,
Err(e) => format!("Error: {e}"),
};
}
let kind = p.kind.unwrap_or(40001);
let channel_tag = match nostr::Tag::parse(&["channel", &p.channel_id]) {
Ok(t) => t,
Err(e) => return format!("Error building channel tag: {e}"),
};
let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) {
Ok(t) => t,
Err(e) => return format!("Error building event-ref tag: {e}"),
};
let keys = self.client.keys().clone();
let event = match nostr::EventBuilder::new(
nostr::Kind::Custom(kind),
&p.content,
[channel_tag, event_ref_tag],
)
.sign_with_keys(&keys)
{
Ok(e) => e,
Err(e) => return format!("Error signing event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) if ok.accepted => format!("Message sent. Event ID: {}", ok.event_id),
Ok(ok) => format!("Message rejected: {}", ok.message),
Err(e) => format!("Relay error: {e}"),
}
}
@@ -546,27 +524,15 @@ impl SproutMcpServer {
/// Create a new Sprout channel.
#[tool(name = "create_channel", description = "Create a new Sprout channel")]
pub async fn create_channel(&self, Parameters(p): Parameters<CreateChannelParams>) -> String {
let keys = self.client.keys().clone();
let metadata = serde_json::json!({
let body = serde_json::json!({
"name": p.name,
"channel_type": p.channel_type,
"visibility": p.visibility,
"description": p.description,
});
let event =
match nostr::EventBuilder::new(nostr::Kind::Custom(40), metadata.to_string(), [])
.sign_with_keys(&keys)
{
Ok(e) => e,
Err(e) => return format!("Error signing event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) if ok.accepted => format!("Channel created. Event ID: {}", ok.event_id),
Ok(ok) => format!("Channel creation rejected: {}", ok.message),
Err(e) => format!("Relay error: {e}"),
match self.client.post("/api/channels", &body).await {
Ok(b) => b,
Err(e) => format!("Error: {e}"),
}
}
@@ -627,11 +593,15 @@ impl SproutMcpServer {
Ok(t) => t,
Err(e) => return format!("Error building tag: {e}"),
};
let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) {
Ok(t) => t,
Err(e) => return format!("Error building event-ref tag: {e}"),
};
let event = match nostr::EventBuilder::new(
nostr::Kind::Custom(KIND_CANVAS as u16),
&p.content,
[channel_tag],
[channel_tag, event_ref_tag],
)
.sign_with_keys(&keys)
{
@@ -1341,6 +1311,7 @@ impl SproutMcpServer {
let body = serde_json::json!({
"display_name": p.display_name,
"avatar_url": p.avatar_url,
"about": p.about,
});
match self.client.put("/api/users/me/profile", &body).await {
Ok(b) => b,

View File

@@ -27,7 +27,7 @@ pub async fn agents_handler(
// Get requester's accessible channels to filter bot channel visibility.
let accessible_channels = state
.db
.get_accessible_channels(&pubkey_bytes)
.get_accessible_channels(&pubkey_bytes, None)
.await
.map_err(|e| {
tracing::error!("agents: failed to load accessible channels: {e}");

View File

@@ -9,7 +9,7 @@ use std::sync::Arc;
use axum::{
extract::Json as ExtractJson,
extract::State,
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::Json,
};
@@ -21,18 +21,26 @@ use crate::state::AppState;
use super::{api_error, extract_auth_pubkey, internal_error};
/// Query parameters for `GET /api/channels`.
#[derive(Debug, Deserialize)]
pub struct ListChannelsParams {
/// Optional visibility filter: `"open"` or `"private"`.
pub visibility: Option<String>,
}
/// Returns all channels accessible to the authenticated user.
///
/// For DM channels, resolves participant display names and pubkeys.
pub async fn channels_handler(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<ListChannelsParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let channels = state
.db
.get_accessible_channels(&pubkey_bytes)
.get_accessible_channels(&pubkey_bytes, params.visibility.as_deref())
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;

View File

@@ -131,9 +131,12 @@ pub struct UpdateChannelBody {
pub description: Option<String>,
}
/// PUT /api/channels/{channel_id} — Update channel name and/or description.
/// Update channel properties (name, description).
///
/// Requires owner or admin role.
/// Requires owner or admin role. Topic and purpose are settable by any member
/// via separate endpoints — see `channels_metadata.rs`. This asymmetry is
/// intentional: name and description are structural metadata, while topic and
/// purpose are collaborative content metadata (NIP-29 parity).
pub async fn update_channel_handler(
State(state): State<Arc<AppState>>,
headers: HeaderMap,

View File

@@ -57,6 +57,37 @@ fn effective_author(event: &nostr::Event, relay_pubkey: &nostr::PublicKey) -> Ve
event.pubkey.serialize().to_vec()
}
/// Resolve the effective author pubkey from stored (non-Event) data.
///
/// REST-created messages are signed by the relay keypair and carry the real
/// sender in the first `p` tag. This helper mirrors `effective_author` but
/// works with raw bytes + stored tags JSON rather than a `nostr::Event`.
fn effective_author_bytes(
msg_pubkey: &[u8],
tags: &serde_json::Value,
relay_pubkey_bytes: &[u8],
) -> Vec<u8> {
if msg_pubkey == relay_pubkey_bytes {
// Relay-signed: real author is in the first p tag.
if let Some(tags_arr) = tags.as_array() {
for tag in tags_arr {
if let Some(arr) = tag.as_array() {
if arr.len() >= 2 && arr[0].as_str() == Some("p") {
if let Some(hex) = arr[1].as_str() {
if let Ok(bytes) = nostr_hex::decode(hex) {
if bytes.len() == 32 {
return bytes;
}
}
}
}
}
}
}
}
msg_pubkey.to_vec()
}
/// Serialize a slice of reaction summaries to JSON.
fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serde_json::Value {
serde_json::json!(reactions
@@ -434,12 +465,16 @@ pub async fn list_messages(
// Determine next_cursor from the oldest message in this page.
let next_cursor = messages.last().map(|m| m.created_at.timestamp());
// Compute relay pubkey bytes once for effective-author resolution.
let relay_pk_bytes = state.relay_keypair.public_key().serialize().to_vec();
let result: Vec<serde_json::Value> = messages
.iter()
.map(|m| {
let author = effective_author_bytes(&m.pubkey, &m.tags, &relay_pk_bytes);
let mut obj = serde_json::json!({
"event_id": nostr_hex::encode(&m.event_id),
"pubkey": nostr_hex::encode(&m.pubkey),
"pubkey": nostr_hex::encode(&author),
"content": m.content,
"kind": m.kind,
"created_at": m.created_at.timestamp(),
@@ -573,9 +608,12 @@ pub async fn get_thread(
// Serialize root event.
let root_created_at = root_event.event.created_at.as_u64() as i64;
let relay_pk = state.relay_keypair.public_key();
let relay_pk_bytes = relay_pk.serialize().to_vec();
let root_author = effective_author(&root_event.event, &relay_pk);
let mut root_obj = serde_json::json!({
"event_id": root_event.event.id.to_hex(),
"pubkey": root_event.event.pubkey.to_hex(),
"pubkey": nostr_hex::encode(&root_author),
"content": root_event.event.content,
"kind": root_event.event.kind.as_u16(),
"created_at": root_created_at,
@@ -620,12 +658,13 @@ pub async fn get_thread(
let reply_objs: Vec<serde_json::Value> = replies
.iter()
.map(|r| {
let reply_author = effective_author_bytes(&r.pubkey, &r.tags, &relay_pk_bytes);
let mut obj = serde_json::json!({
"event_id": nostr_hex::encode(&r.event_id),
"parent_event_id": r.parent_event_id.as_ref().map(nostr_hex::encode),
"root_event_id": r.root_event_id.as_ref().map(nostr_hex::encode),
"channel_id": r.channel_id.to_string(),
"pubkey": nostr_hex::encode(&r.pubkey),
"pubkey": nostr_hex::encode(&reply_author),
"content": r.content,
"kind": r.kind,
"depth": r.depth,

View File

@@ -71,6 +71,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::Json,
};
use sha2::{Digest, Sha256};
use crate::state::AppState;
@@ -111,9 +112,11 @@ fn decode_jwt_payload_unverified(
/// Extract an authenticated pubkey from the request headers.
///
/// Auth resolution order:
/// 1. `Authorization: Bearer <jwt>` — validated via JWKS when `require_auth_token=true`,
/// or decoded unverified (username → derived key) when `require_auth_token=false`.
/// 2. `X-Pubkey: <hex>` — accepted only when `require_auth_token=false` (dev mode).
/// 1. `Authorization: Bearer sprout_*` — API token; hashed, looked up in DB.
/// 2. `Authorization: Bearer eyJ*` — Okta JWT; validated via JWKS when
/// `require_auth_token=true`, or decoded unverified (username → derived key) when
/// `require_auth_token=false`.
/// 3. `X-Pubkey: <hex>` — accepted only when `require_auth_token=false` (dev mode).
///
/// Returns `(nostr::PublicKey, pubkey_bytes)` on success, or a 401 response on failure.
pub(crate) async fn extract_auth_pubkey(
@@ -122,9 +125,56 @@ pub(crate) async fn extract_auth_pubkey(
) -> Result<(nostr::PublicKey, Vec<u8>), (StatusCode, Json<serde_json::Value>)> {
let require_auth = state.config.require_auth_token;
// Try Authorization: Bearer <jwt>
// Try Authorization: Bearer <token>
if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
if let Some(token) = auth_header.strip_prefix("Bearer ") {
// ── API token path (sprout_*) ─────────────────────────────────
// Must be checked before the Okta JWT path: verify_auth_event()
// (and validate_bearer_jwt) would reject sprout_ tokens immediately.
if token.starts_with("sprout_") {
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
let record = match state.db.get_api_token_by_hash(&hash).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("auth: API token lookup failed: {e}");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
};
let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
Ok(pk) => pk,
Err(e) => {
tracing::warn!("auth: API token owner pubkey invalid: {e}");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
};
// verify_api_token_against_hash checks hash match, expiry, and
// pubkey equality. For the REST path there is no "claimed" pubkey
// from a Nostr event, so we pass the owner pubkey for both
// arguments — we are asserting identity from the DB record itself.
match state.auth.verify_api_token_against_hash(
token,
&record.token_hash,
&owner_pubkey,
&owner_pubkey,
record.expires_at,
&record.scopes,
) {
Ok((pubkey, _scopes)) => {
let bytes = pubkey.serialize().to_vec();
if let Err(e) = state.db.ensure_user(&bytes).await {
tracing::warn!("ensure_user failed: {e}");
}
// Update last_used_at — non-fatal.
let _ = state.db.update_token_last_used(&hash).await;
return Ok((pubkey, bytes));
}
Err(_) => {
tracing::warn!("auth: API token verification failed");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
}
}
if require_auth {
// Production: validate JWT against JWKS
match state.auth.validate_bearer_jwt(token).await {

View File

@@ -2,7 +2,7 @@
//!
//! Endpoints:
//! GET /api/users/me/profile — get own profile
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url)
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about)
use std::sync::Arc;
@@ -19,18 +19,20 @@ use crate::state::AppState;
use super::{api_error, extract_auth_pubkey, internal_error};
/// Request body for updating a user's profile.
/// Both fields are optional — at least one must be present.
/// All fields are optional — at least one must be present.
#[derive(Debug, Deserialize)]
pub struct UpdateProfileBody {
/// New display name for the user, or `None` to leave unchanged.
pub display_name: Option<String>,
/// New avatar URL for the user, or `None` to leave unchanged.
pub avatar_url: Option<String>,
/// Short bio or description, or `None` to leave unchanged.
pub about: Option<String>,
}
/// `PUT /api/users/me/profile` — update the authenticated user's profile.
///
/// Body: `{ "display_name": "Alice", "avatar_url": "https://..." }` (both optional, at least one required)
/// Body: `{ "display_name": "Alice", "avatar_url": "https://...", "about": "..." }` (all optional, at least one required)
/// Returns: `{ "updated": true }`
pub async fn update_profile(
State(state): State<Arc<AppState>>,
@@ -49,17 +51,22 @@ pub async fn update_profile(
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let about = body
.about
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if display_name.is_none() && avatar_url.is_none() {
if display_name.is_none() && avatar_url.is_none() && about.is_none() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"at least one of display_name or avatar_url is required",
"at least one of display_name, avatar_url, or about is required",
));
}
state
.db
.update_user_profile(&pubkey_bytes, display_name, avatar_url)
.update_user_profile(&pubkey_bytes, display_name, avatar_url, about)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
@@ -68,7 +75,7 @@ pub async fn update_profile(
/// `GET /api/users/me/profile` — get the authenticated user's profile.
///
/// Returns: `{ "pubkey": "<hex>", "display_name": "...", "avatar_url": "...", "nip05_handle": "..." }`
/// Returns: `{ "pubkey": "<hex>", "display_name": "...", "avatar_url": "...", "about": "...", "nip05_handle": "..." }`
pub async fn get_profile(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
@@ -86,6 +93,7 @@ pub async fn get_profile(
"pubkey": nostr_hex::encode(&p.pubkey),
"display_name": p.display_name,
"avatar_url": p.avatar_url,
"about": p.about,
"nip05_handle": p.nip05_handle,
}))),
None => Err(api_error(StatusCode::NOT_FOUND, "user not found")),

View File

@@ -2,6 +2,7 @@
use std::sync::Arc;
use sha2::{Digest, Sha256};
use tracing::{debug, info, warn};
use crate::connection::{AuthState, ConnectionState};
@@ -40,6 +41,96 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
let auth_svc = Arc::clone(&state.auth);
let event_id_hex = event.id.to_hex();
// Extract the auth_token tag before dispatching — API tokens (sprout_*) must be
// intercepted here because verify_auth_event() has no DB access and rejects them.
let auth_token = event.tags.iter().find_map(|tag| {
let vec = tag.as_slice();
if vec.len() >= 2 && vec[0] == "auth_token" {
Some(vec[1].to_string())
} else {
None
}
});
if let Some(ref token) = auth_token {
if token.starts_with("sprout_") {
// ── API token path ──────────────────────────────────────────────
// Hash the raw token and look it up in the DB. The relay owns this
// path; sprout-auth has no DB access.
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
let record = match state.db.get_api_token_by_hash(&hash).await {
Ok(r) => r,
Err(e) => {
warn!(conn_id = %conn_id, error = %e, "API token lookup failed");
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"auth-required: verification failed",
));
return;
}
};
// Reconstruct the owner pubkey from the stored raw bytes.
let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
Ok(pk) => pk,
Err(e) => {
warn!(conn_id = %conn_id, error = %e, "API token owner pubkey invalid");
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"auth-required: verification failed",
));
return;
}
};
// Verify hash, expiry, and pubkey match via the auth service.
match auth_svc.verify_api_token_against_hash(
token,
&record.token_hash,
&owner_pubkey,
&event.pubkey,
record.expires_at,
&record.scopes,
) {
Ok((pubkey, scopes)) => {
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "API token auth successful");
// Update last_used_at asynchronously — non-fatal if it fails.
let db = state.db.clone();
let hash_owned = hash;
tokio::spawn(async move {
if let Err(e) = db.update_token_last_used(&hash_owned).await {
warn!("update_token_last_used failed: {e}");
}
});
let auth_ctx = sprout_auth::AuthContext {
pubkey,
scopes,
auth_method: sprout_auth::AuthMethod::Nip42ApiToken,
};
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
conn.send(RelayMessage::ok(&event_id_hex, true, ""));
}
Err(e) => {
warn!(conn_id = %conn_id, error = %e, "API token verification failed");
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"auth-required: verification failed",
));
}
}
return;
}
}
// ── Okta JWT / pubkey-only path ─────────────────────────────────────────
// Non-sprout_ tokens (eyJ* JWTs) and no-token (open-relay) fall through here.
match auth_svc
.verify_auth_event(event, &challenge, &relay_url)
.await

View File

@@ -507,15 +507,51 @@ pub async fn dispatch_action(
action: &ActionDef,
_engine: &WorkflowEngine,
run_id: Uuid,
trigger_ctx: &TriggerContext,
) -> Result<StepResult, WorkflowError> {
use ActionDef::*;
match action {
SendMessage { text, channel } => {
let target = channel.as_deref().unwrap_or("<workflow channel>");
info!(run_id = %run_id, step = step_id, "SendMessage → {target}: {text}");
// TODO (WF-07): emit kind:40001 event via engine's event emitter.
Ok(StepResult::Completed(serde_json::json!({ "sent": true })))
// Use explicit channel override if provided; otherwise fall back to
// the channel that triggered this workflow run.
let channel_id = channel
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&trigger_ctx.channel_id);
info!(
run_id = %run_id,
step = step_id,
channel = %channel_id,
"SendMessage → {channel_id}: {text}"
);
if channel_id.is_empty() {
return Err(WorkflowError::InvalidDefinition(
"SendMessage: no channel_id available (trigger has no channel context and \
no channel override was specified)"
.into(),
));
}
#[cfg(feature = "reqwest")]
{
let result = send_message_impl(channel_id, text).await?;
Ok(StepResult::Completed(result))
}
#[cfg(not(feature = "reqwest"))]
{
warn!(
run_id = %run_id,
step = step_id,
"SendMessage: reqwest feature not enabled, skipping HTTP call"
);
Ok(StepResult::Completed(
serde_json::json!({ "sent": false, "skipped": true }),
))
}
}
SendDm { to, text } => {
@@ -793,6 +829,82 @@ async fn call_webhook_impl(
}))
}
// ── send_message implementation (feature-gated) ──────────────────────────────
/// POST `{"content": text}` to `POST /api/channels/{channel_id}/messages`.
///
/// Relay base URL is read from `SPROUT_RELAY_BASE_URL` (default: `http://localhost:3000`).
///
/// Auth strategy (in priority order):
/// 1. `SPROUT_API_TOKEN` env var → `Authorization: Bearer <token>` (production)
/// 2. `SPROUT_RELAY_PUBKEY` env var → `X-Pubkey: <hex>` (dev mode only,
/// requires `SPROUT_REQUIRE_AUTH_TOKEN=false`)
///
/// NOTE: This implementation uses X-Pubkey auth which only works in dev mode
/// (SPROUT_REQUIRE_AUTH_TOKEN=false). For production, the executor needs to
/// either: (a) use an API token (SPROUT_API_TOKEN env var), or (b) sign
/// events directly and submit via WebSocket. See WF-07.
/// TODO(WF-07): Support production auth for workflow-generated messages.
///
/// This is an internal call — the workflow engine runs inside the relay process,
/// so `localhost:3000` is always reachable without SSRF concerns.
#[cfg(feature = "reqwest")]
async fn send_message_impl(channel_id: &str, text: &str) -> Result<JsonValue, WorkflowError> {
use reqwest::Client;
use std::time::Duration;
let base_url = std::env::var("SPROUT_RELAY_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_owned());
let url = format!("{base_url}/api/channels/{channel_id}/messages");
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| WorkflowError::WebhookError(format!("failed to build HTTP client: {e}")))?;
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "content": text }));
// Attach auth header: prefer API token (production), fall back to X-Pubkey (dev mode).
if let Ok(token) = std::env::var("SPROUT_API_TOKEN") {
req = req.header("Authorization", format!("Bearer {token}"));
} else if let Ok(pubkey) = std::env::var("SPROUT_RELAY_PUBKEY") {
req = req.header("X-Pubkey", pubkey);
}
let resp = req
.send()
.await
.map_err(|e| WorkflowError::WebhookError(format!("SendMessage HTTP error: {e}")))?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable>".to_owned());
return Err(WorkflowError::WebhookError(format!(
"SendMessage: relay returned {status} for channel {channel_id}: {body}"
)));
}
let body_text = resp.text().await.unwrap_or_else(|_| String::new());
// Try to parse the response as JSON; fall back to wrapping the raw text.
let body_json: JsonValue = serde_json::from_str(&body_text)
.unwrap_or_else(|_| serde_json::json!({ "raw": body_text }));
Ok(serde_json::json!({
"sent": true,
"status": status.as_u16(),
"response": body_json,
}))
}
// ── Execution result ──────────────────────────────────────────────────────────
/// Rich return type from `execute_run` / `execute_from_step`.
@@ -997,7 +1109,7 @@ async fn execute_steps(
.unwrap_or(engine.config.default_timeout_secs);
let dispatch_result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
dispatch_action(&step.id, &resolved_action, engine, run_id),
dispatch_action(&step.id, &resolved_action, engine, run_id, trigger_ctx),
)
.await;

View File

@@ -18,14 +18,18 @@ export default defineConfig({
projects: [
{
name: "smoke",
testMatch: "**/smoke.spec.ts",
testMatch: [
"**/smoke.spec.ts",
"**/channels.spec.ts",
"**/messaging.spec.ts",
],
use: {
...devices["Desktop Chrome"],
},
},
{
name: "integration",
testMatch: "**/stream.spec.ts",
testMatch: ["**/stream.spec.ts", "**/integration.spec.ts"],
use: {
...devices["Desktop Chrome"],
},

View File

@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("sidebar shows all channel types", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
// Streams
const streamList = page.getByTestId("stream-list");
await expect(streamList).toContainText("general");
await expect(streamList).toContainText("random");
await expect(streamList).toContainText("engineering");
await expect(streamList).toContainText("agents");
// Forums
const forumList = page.getByTestId("forum-list");
await expect(forumList).toContainText("watercooler");
await expect(forumList).toContainText("announcements");
// DMs
const dmList = page.getByTestId("dm-list");
await expect(dmList).toContainText("alice-tyler");
await expect(dmList).toContainText("bob-tyler");
});
test("create stream with name and description", async ({ page }) => {
const channelName = `my-new-stream-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("A stream for testing channel creation");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("create stream with special characters", async ({ page }) => {
const channelName = `dev ops-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("Stream with spaces and hyphens");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("switch between streams", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
});
test("switch between channel types", async ({ page }) => {
await page.goto("/");
// Stream
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Forum
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
// DM
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
});
test("empty channel shows empty state", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("message-empty")).toBeVisible();
});
test("channel with messages shows content", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
);
});
test("sidebar persists after channel switch", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
});

View File

@@ -0,0 +1,164 @@
import { expect, test, type Browser } from "@playwright/test";
import { installRelayBridge } from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
test.beforeAll(async () => {
await assertRelaySeeded();
});
test("create channel and verify in sidebar", async ({ page }) => {
const channelName = `integration-e2e-${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("two users see the same channel", async ({
browser,
}: {
browser: Browser;
}) => {
const channelName = `shared-channel-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageOne.getByRole("button", { name: "Create a stream" }).click();
await pageOne.getByTestId("create-stream-name").fill(channelName);
await pageOne.getByRole("button", { name: "Create" }).click();
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
await pageTwo.goto("/");
await expect(pageTwo.getByTestId("stream-list")).toContainText(channelName);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("message delivery across users", async ({
browser,
}: {
browser: Browser;
}) => {
const message = `Cross-user message ${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
await pageOne.getByTestId("channel-general").click();
await pageTwo.getByTestId("channel-general").click();
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
await pageOne.getByTestId("message-input").fill(message);
await pageOne.getByTestId("send-message").click();
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
message,
);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("DM channel appears in sidebar", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
});
test("send message to DM", async ({ page }) => {
const message = `DM message ${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("forum channel appears in sidebar", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
});
test("create channel with description", async ({ page }) => {
const channelName = `desc-channel-${Date.now()}`;
const description = `Description for ${channelName}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page.getByTestId("create-stream-description").fill(description);
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("chat-description")).toContainText(description);
});
test("multiple channels independent", async ({ page }) => {
const channelA = `channel-a-${Date.now()}`;
const channelB = `channel-b-${Date.now()}`;
const messageA = `Message in A ${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
// Create channel A
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelA);
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
// Create channel B
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelB);
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
// Navigate to channel A and send a message
await page.getByTestId(`channel-${channelA}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
await page.getByTestId("message-input").fill(messageA);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(messageA);
// Switch to channel B — message from A should not appear
await page.getByTestId(`channel-${channelB}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
await expect(page.getByTestId("message-timeline")).not.toContainText(
messageA,
);
});

View File

@@ -0,0 +1,164 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("send a message and see it in timeline", async ({ page }) => {
const message = `Hello timeline ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("send multiple messages in sequence", async ({ page }) => {
const ts = Date.now();
const messages = [
`First message ${ts}`,
`Second message ${ts}`,
`Third message ${ts}`,
];
const input = page.getByTestId("message-input");
const sendButton = page.getByTestId("send-message");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
for (const message of messages) {
await input.fill(message);
await sendButton.click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
}
const timeline = page.getByTestId("message-timeline");
for (const message of messages) {
await expect(timeline).toContainText(message);
}
});
test("message input clears after send", async ({ page }) => {
const message = `Clear after send ${Date.now()}`;
const input = page.getByTestId("message-input");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await input.fill(message);
await expect(input).toHaveValue(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
await expect(input).toHaveValue("");
});
test("empty message cannot be sent", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const sendButton = page.getByTestId("send-message");
await expect(sendButton).toBeDisabled();
});
test("send message with Enter key", async ({ page }) => {
const message = `Enter key send ${Date.now()}`;
const input = page.getByTestId("message-input");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await input.fill(message);
await input.press("Enter");
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("messages persist across channel switches", async ({ page }) => {
const message = `Persist across switch ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("different channels have independent messages", async ({ page }) => {
const ts = Date.now();
const generalMessage = `General only ${ts}`;
const randomMessage = `Random only ${ts}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill(generalMessage);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(
generalMessage,
);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("message-timeline")).not.toContainText(
generalMessage,
);
await page.getByTestId("message-input").fill(randomMessage);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(
randomMessage,
);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
generalMessage,
);
await expect(page.getByTestId("message-timeline")).not.toContainText(
randomMessage,
);
});
test("day divider appears in timeline", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
);
await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible();
});
test("send message to DM channel", async ({ page }) => {
const message = `DM message ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
});

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN about TEXT DEFAULT NULL AFTER avatar_url;