|
|
|
@@ -0,0 +1,499 @@
|
|
|
|
|
//! `app.bsky.actor.profile.get` and `app.bsky.actor.profile.set`.
|
|
|
|
|
//!
|
|
|
|
|
//! ### `get`
|
|
|
|
|
//!
|
|
|
|
|
//! Read the profile record (CBOR-decoded `app.bsky.actor.profile/self`
|
|
|
|
|
//! value block) for the authenticated user. The handle/did come from
|
|
|
|
|
//! the JWT; the record is parsed into a JSON object. Returns `null`
|
|
|
|
|
//! for the `profile` field when the user has no profile yet (a brand
|
|
|
|
|
//! new account).
|
|
|
|
|
//!
|
|
|
|
|
//! ### `set`
|
|
|
|
|
//!
|
|
|
|
|
//! Read-modify-write of the user's `app.bsky.actor.profile/self`
|
|
|
|
|
//! record. The body carries only the fields the caller wants to
|
|
|
|
|
//! change; the existing record is fetched and the supplied fields
|
|
|
|
|
//! overwrite the corresponding fields. Best-effort push to the
|
|
|
|
|
//! AppView follows so the `profiles` cache reflects the new avatar /
|
|
|
|
|
//! display name / bio without waiting for the Jetstream replay.
|
|
|
|
|
use crate::jwt_issuer;
|
|
|
|
|
use crate::routes::helpers::{
|
|
|
|
|
apply_repo_write, err, load_head_commit, load_signing_key, load_user_blockstore,
|
|
|
|
|
to_sqlx_error, RepoWriteOutcome,
|
|
|
|
|
};
|
|
|
|
|
use crate::routes::types::ErrorBody;
|
|
|
|
|
use crate::state::AppState;
|
|
|
|
|
use at_crypto::cid::cid_for_cbor;
|
|
|
|
|
use at_repo::blockstore::Blockstore as _;
|
|
|
|
|
use at_repo::repo::Repo;
|
|
|
|
|
use axum::extract::State;
|
|
|
|
|
use axum::http::StatusCode;
|
|
|
|
|
use bytes::Bytes;
|
|
|
|
|
use cid::Cid;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use tracing::{info, warn};
|
|
|
|
|
|
|
|
|
|
/// Blob metadata looked up from the `blobs` table. Used to write
|
|
|
|
|
/// the `mimeType` / `size` fields of a profile avatar/banner ref —
|
|
|
|
|
/// these must reflect what the user actually uploaded, not a
|
|
|
|
|
/// hardcoded constant.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub(crate) struct ResolvedBlob {
|
|
|
|
|
pub mime_type: String,
|
|
|
|
|
pub size: i64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct GetProfileResp {
|
|
|
|
|
pub did: String,
|
|
|
|
|
pub handle: String,
|
|
|
|
|
/// `null` when the user has no profile record yet.
|
|
|
|
|
pub profile: Option<Value>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct SetProfileReq {
|
|
|
|
|
/// Optional new display name. `null`/missing preserves the
|
|
|
|
|
/// existing record's `displayName`.
|
|
|
|
|
pub display_name: Option<String>,
|
|
|
|
|
/// Optional new bio / description. `null`/missing preserves.
|
|
|
|
|
pub description: Option<String>,
|
|
|
|
|
/// CID of the avatar blob, already uploaded via uploadBlob.
|
|
|
|
|
/// `null`/missing preserves.
|
|
|
|
|
pub avatar_blob_cid: Option<String>,
|
|
|
|
|
/// CID of the banner blob. `null`/missing preserves.
|
|
|
|
|
pub banner_blob_cid: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Read the authenticated user's profile record.
|
|
|
|
|
pub async fn get_profile(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: axum::http::HeaderMap,
|
|
|
|
|
) -> Result<axum::Json<GetProfileResp>, (StatusCode, axum::Json<ErrorBody>)> {
|
|
|
|
|
let did = authenticate(&headers, &state)?;
|
|
|
|
|
let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE did = $1")
|
|
|
|
|
.bind(&did)
|
|
|
|
|
.fetch_one(&state.db)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let profile = read_profile_record(&state, &did).await?;
|
|
|
|
|
Ok(axum::Json(GetProfileResp { did, handle, profile }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Read-modify-write the authenticated user's profile record.
|
|
|
|
|
pub async fn set_profile(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: axum::http::HeaderMap,
|
|
|
|
|
axum::Json(req): axum::Json<SetProfileReq>,
|
|
|
|
|
) -> Result<axum::Json<GetProfileResp>, (StatusCode, axum::Json<ErrorBody>)> {
|
|
|
|
|
let did = authenticate(&headers, &state)?;
|
|
|
|
|
let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE did = $1")
|
|
|
|
|
.bind(&did)
|
|
|
|
|
.fetch_one(&state.db)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// Fetch the existing record, if any.
|
|
|
|
|
let existing = read_profile_record(&state, &did).await?;
|
|
|
|
|
|
|
|
|
|
// For any blob CIDs in the request, look up the real
|
|
|
|
|
// `mime_type` / `size` from the `blobs` table — and verify
|
|
|
|
|
// ownership (the blob must belong to the authenticated DID).
|
|
|
|
|
// Without the ownership check a session for DID A could
|
|
|
|
|
// reference DID B's blob in their profile.
|
|
|
|
|
let avatar = match req.avatar_blob_cid.as_deref() {
|
|
|
|
|
Some(cid) => Some(resolve_owned_blob(&state, &did, cid).await?),
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
|
|
|
|
let banner = match req.banner_blob_cid.as_deref() {
|
|
|
|
|
Some(cid) => Some(resolve_owned_blob(&state, &did, cid).await?),
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Merge: start from the existing record (or empty object), then
|
|
|
|
|
// overlay the supplied fields. We use the atproto standard
|
|
|
|
|
// `app.bsky.actor.profile` schema: displayName (string),
|
|
|
|
|
// description (string), avatar (blob ref), banner (blob ref).
|
|
|
|
|
let next = merge_profile_fields(existing, &req, avatar.as_ref(), banner.as_ref());
|
|
|
|
|
|
|
|
|
|
// Validate against the lexicon so the user can't push an
|
|
|
|
|
// arbitrary JSON shape that wouldn't round-trip through a real
|
|
|
|
|
// atproto client.
|
|
|
|
|
if let Err(e) = state.lex.validate("app.bsky.actor.profile", &next) {
|
|
|
|
|
return Err(err(
|
|
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
|
|
"InvalidRequest",
|
|
|
|
|
format!("lex validation failed: {e}"),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Encode the new record as CBOR and write it to the user's
|
|
|
|
|
// `app.bsky.actor.profile/self` MST via the canonical repo-write
|
|
|
|
|
// path (the same one createRecord uses).
|
|
|
|
|
let value_cid: Cid = {
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
ciborium::into_writer(&next, &mut buf).map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("cbor: {e}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
cid_for_cbor(&buf).map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
e.to_string(),
|
|
|
|
|
)
|
|
|
|
|
})?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let next_for_block = next.clone();
|
|
|
|
|
let outcome = apply_repo_write(&state, &did, move |repo| {
|
|
|
|
|
let value_cid = value_cid;
|
|
|
|
|
let next_for_block = next_for_block;
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
repo.blockstore
|
|
|
|
|
.put(&value_cid, Bytes::from({
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
ciborium::into_writer(&next_for_block, &mut buf).unwrap();
|
|
|
|
|
buf
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(to_sqlx_error)?;
|
|
|
|
|
let (_uri, _returned_cid) = repo
|
|
|
|
|
.put_record("app.bsky.actor.profile", "self", value_cid)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(to_sqlx_error)?;
|
|
|
|
|
let commit = repo.commit().await.map_err(to_sqlx_error)?;
|
|
|
|
|
let head_cid_bytes = commit.cid.to_bytes().to_vec();
|
|
|
|
|
let head_commit_bytes = commit.signed_bytes.clone();
|
|
|
|
|
Ok(RepoWriteOutcome {
|
|
|
|
|
commit,
|
|
|
|
|
head_cid_bytes,
|
|
|
|
|
head_commit_bytes,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
did = %did,
|
|
|
|
|
cid = %outcome.commit.cid,
|
|
|
|
|
"profile record created"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Best-effort push to the AppView so the profile cache reflects
|
|
|
|
|
// the new avatar / display name / bio without waiting for the
|
|
|
|
|
// Jetstream `identity` event to plumb through.
|
|
|
|
|
if let Err(e) = state
|
|
|
|
|
.appview
|
|
|
|
|
.push_profile(&did, &handle, &next)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
warn!(error = %e, "profile push to AppView failed; Jetstream will catch up");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(axum::Json(GetProfileResp {
|
|
|
|
|
did,
|
|
|
|
|
handle,
|
|
|
|
|
profile: Some(next),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- internals ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Verify a bearer token and return the authenticated DID.
|
|
|
|
|
fn authenticate(
|
|
|
|
|
headers: &axum::http::HeaderMap,
|
|
|
|
|
state: &AppState,
|
|
|
|
|
) -> Result<String, (StatusCode, axum::Json<ErrorBody>)> {
|
|
|
|
|
let token = headers
|
|
|
|
|
.get("authorization")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.and_then(|s| s.strip_prefix("Bearer "))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"Unauthenticated",
|
|
|
|
|
"missing Authorization: Bearer header",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let server_pk = jwt_issuer::server_p256_public_multibase(&state.cfg)
|
|
|
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
|
|
|
|
let claims = at_crypto::jwt::verify_jwt(token, &server_pk).map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"TokenInvalid",
|
|
|
|
|
format!("invalid token: {e}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
Ok(claims.sub)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Decode the `app.bsky.actor.profile/self` record for `did`, if any.
|
|
|
|
|
/// Returns `Ok(None)` when the record doesn't exist.
|
|
|
|
|
///
|
|
|
|
|
/// Walk the head commit down to the profile/self leaf, fetch the
|
|
|
|
|
/// value block, CBOR-decode it into JSON. Mirrors `get_record`'s
|
|
|
|
|
/// walk in routes/sync.rs.
|
|
|
|
|
async fn read_profile_record(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
did: &str,
|
|
|
|
|
) -> Result<Option<Value>, (StatusCode, axum::Json<ErrorBody>)> {
|
|
|
|
|
let (head_cid, head_commit_bytes) = match load_head_commit(state, did).await? {
|
|
|
|
|
Some(t) => t,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
let signing_key_bytes: Vec<u8> =
|
|
|
|
|
sqlx::query_scalar("SELECT signing_key FROM users WHERE did = $1")
|
|
|
|
|
.bind(did)
|
|
|
|
|
.fetch_one(&state.db)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("users.signing_key read: {e}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let signing_key = load_signing_key(&signing_key_bytes)?;
|
|
|
|
|
|
|
|
|
|
let blockstore = load_user_blockstore(state, did).await?;
|
|
|
|
|
blockstore
|
|
|
|
|
.put(&head_cid, Bytes::from(head_commit_bytes.clone()))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("blockstore put head: {e:#}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let repo: Repo<_> = Repo::load(did.to_string(), signing_key, blockstore.clone(), head_cid)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("repo load: {e:#}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let value_cid = match repo.get_record("app.bsky.actor.profile", "self").await {
|
|
|
|
|
Ok(Some(c)) => c,
|
|
|
|
|
Ok(None) => return Ok(None),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
return Err(err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("repo.get_record: {e:#}"),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let value_bytes = match blockstore.get(&value_cid).await.map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("blockstore get value: {e:#}"),
|
|
|
|
|
)
|
|
|
|
|
})? {
|
|
|
|
|
Some(b) => b,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
let v: Value = ciborium::from_reader(&value_bytes[..]).map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("cbor decode: {e}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
Ok(Some(v))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Overlay `req` onto `existing`, returning the merged profile
|
|
|
|
|
/// record. Each `Some(_)` field in `req` overwrites the matching
|
|
|
|
|
/// field; `None` fields are preserved.
|
|
|
|
|
///
|
|
|
|
|
/// `avatar` / `banner` are the resolved blobs (looked up from the
|
|
|
|
|
/// `blobs` table in `set_profile` so we can write the real
|
|
|
|
|
/// `mimeType` / `size`). When `None`, the avatar/banner fields are
|
|
|
|
|
/// preserved from `existing`.
|
|
|
|
|
///
|
|
|
|
|
/// Extracted from `set_profile` so the merge semantics are testable
|
|
|
|
|
/// without a running PDS / DB.
|
|
|
|
|
pub(crate) fn merge_profile_fields(
|
|
|
|
|
existing: Option<Value>,
|
|
|
|
|
req: &SetProfileReq,
|
|
|
|
|
avatar: Option<&ResolvedBlob>,
|
|
|
|
|
banner: Option<&ResolvedBlob>,
|
|
|
|
|
) -> Value {
|
|
|
|
|
let mut next: Value = existing.unwrap_or_else(|| json!({}));
|
|
|
|
|
if let Some(s) = &req.display_name {
|
|
|
|
|
next["displayName"] = json!(s);
|
|
|
|
|
}
|
|
|
|
|
if let Some(s) = &req.description {
|
|
|
|
|
next["description"] = json!(s);
|
|
|
|
|
}
|
|
|
|
|
if let Some(cid) = &req.avatar_blob_cid {
|
|
|
|
|
// Caller is responsible for the DB lookup; default to a
|
|
|
|
|
// png/0 placeholder only if the lookup somehow returned
|
|
|
|
|
// `None` despite a CID being supplied (shouldn't happen
|
|
|
|
|
// — `resolve_owned_blob` rejects missing blobs earlier).
|
|
|
|
|
let (mime_type, size) = avatar
|
|
|
|
|
.map(|b| (b.mime_type.as_str(), b.size))
|
|
|
|
|
.unwrap_or(("image/png", 0));
|
|
|
|
|
next["avatar"] = json!({
|
|
|
|
|
"$type": "blob",
|
|
|
|
|
"ref": { "$link": cid },
|
|
|
|
|
"mimeType": mime_type,
|
|
|
|
|
"size": size,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if let Some(cid) = &req.banner_blob_cid {
|
|
|
|
|
let (mime_type, size) = banner
|
|
|
|
|
.map(|b| (b.mime_type.as_str(), b.size))
|
|
|
|
|
.unwrap_or(("image/png", 0));
|
|
|
|
|
next["banner"] = json!({
|
|
|
|
|
"$type": "blob",
|
|
|
|
|
"ref": { "$link": cid },
|
|
|
|
|
"mimeType": mime_type,
|
|
|
|
|
"size": size,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Look up a blob by CID and verify it's owned by `did`. The
|
|
|
|
|
/// ownership check is a security requirement: without it a
|
|
|
|
|
/// session for DID A could reference DID B's blob in their own
|
|
|
|
|
/// profile (the value would still resolve at fetch time because
|
|
|
|
|
/// `com.atproto.sync.getBlob` doesn't check ownership, but the
|
|
|
|
|
/// invariant "a profile's avatar belongs to that user" would be
|
|
|
|
|
/// broken).
|
|
|
|
|
async fn resolve_owned_blob(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
did: &str,
|
|
|
|
|
cid: &str,
|
|
|
|
|
) -> Result<ResolvedBlob, (StatusCode, axum::Json<ErrorBody>)> {
|
|
|
|
|
let row: Option<(String, i64)> = sqlx::query_as(
|
|
|
|
|
"SELECT mime_type, size FROM blobs WHERE cid = $1 AND did = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(cid)
|
|
|
|
|
.bind(did)
|
|
|
|
|
.fetch_optional(&state.db)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
err(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
"InternalServerError",
|
|
|
|
|
format!("blobs lookup: {e}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
match row {
|
|
|
|
|
Some((mime_type, size)) => Ok(ResolvedBlob { mime_type, size }),
|
|
|
|
|
None => Err(err(
|
|
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
|
|
"InvalidRequest",
|
|
|
|
|
format!("blob {cid} not found or not owned by {did}"),
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
/// Round-trip the camelCase JSON the Tauri client sends and
|
|
|
|
|
/// confirm every field lands on its `snake_case` Rust
|
|
|
|
|
/// counterpart. Catches regressions of the BLOCKER-3 bug
|
|
|
|
|
/// (silent camelCase → snake_case mismatch that made every
|
|
|
|
|
/// set_profile write an empty record).
|
|
|
|
|
#[test]
|
|
|
|
|
fn set_profile_req_deserializes_camel_case() {
|
|
|
|
|
let raw = json!({
|
|
|
|
|
"displayName": "Alice",
|
|
|
|
|
"description": "hello",
|
|
|
|
|
"avatarBlobCid": "bafyavatar",
|
|
|
|
|
"bannerBlobCid": "bafybanner",
|
|
|
|
|
});
|
|
|
|
|
let req: SetProfileReq = serde_json::from_value(raw).unwrap();
|
|
|
|
|
assert_eq!(req.display_name.as_deref(), Some("Alice"));
|
|
|
|
|
assert_eq!(req.description.as_deref(), Some("hello"));
|
|
|
|
|
assert_eq!(req.avatar_blob_cid.as_deref(), Some("bafyavatar"));
|
|
|
|
|
assert_eq!(req.banner_blob_cid.as_deref(), Some("bafybanner"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `displayName` only — preserves existing description / avatar.
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_preserves_fields_not_in_req() {
|
|
|
|
|
let existing = json!({
|
|
|
|
|
"displayName": "Old",
|
|
|
|
|
"description": "old bio",
|
|
|
|
|
"avatar": { "$type": "blob", "ref": { "$link": "old_avatar" } },
|
|
|
|
|
});
|
|
|
|
|
let req = SetProfileReq {
|
|
|
|
|
display_name: Some("New".into()),
|
|
|
|
|
description: None,
|
|
|
|
|
avatar_blob_cid: None,
|
|
|
|
|
banner_blob_cid: None,
|
|
|
|
|
};
|
|
|
|
|
let next = merge_profile_fields(Some(existing), &req, None, None);
|
|
|
|
|
assert_eq!(next["displayName"], "New");
|
|
|
|
|
assert_eq!(next["description"], "old bio");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
next["avatar"]["ref"]["$link"], "old_avatar",
|
|
|
|
|
"avatar must be preserved when req.avatar_blob_cid is None"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// All fields set on an empty record — the typical first-write
|
|
|
|
|
/// path for a brand-new account.
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_into_empty_record() {
|
|
|
|
|
let req = SetProfileReq {
|
|
|
|
|
display_name: Some("Alice".into()),
|
|
|
|
|
description: Some("first bio".into()),
|
|
|
|
|
avatar_blob_cid: Some("bafyavatar".into()),
|
|
|
|
|
banner_blob_cid: None,
|
|
|
|
|
};
|
|
|
|
|
let avatar = ResolvedBlob {
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
size: 1234,
|
|
|
|
|
};
|
|
|
|
|
let next = merge_profile_fields(None, &req, Some(&avatar), None);
|
|
|
|
|
assert_eq!(next["displayName"], "Alice");
|
|
|
|
|
assert_eq!(next["description"], "first bio");
|
|
|
|
|
assert_eq!(next["avatar"]["ref"]["$link"], "bafyavatar");
|
|
|
|
|
assert_eq!(next["avatar"]["mimeType"], "image/png");
|
|
|
|
|
assert_eq!(next["avatar"]["size"], 1234);
|
|
|
|
|
assert!(next.get("banner").is_none(), "banner must be absent when not set");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Blob refs must use the modern `{ $type, ref.$link, mimeType,
|
|
|
|
|
/// size }` shape so the AppView's `blob_link_of` helper can
|
|
|
|
|
/// parse them back. Locks the wire contract in place.
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_writes_blob_refs_in_modern_shape() {
|
|
|
|
|
let req = SetProfileReq {
|
|
|
|
|
display_name: None,
|
|
|
|
|
description: None,
|
|
|
|
|
avatar_blob_cid: Some("bafyavatar".into()),
|
|
|
|
|
banner_blob_cid: None,
|
|
|
|
|
};
|
|
|
|
|
let avatar = ResolvedBlob {
|
|
|
|
|
mime_type: "image/webp".into(),
|
|
|
|
|
size: 999,
|
|
|
|
|
};
|
|
|
|
|
let next = merge_profile_fields(None, &req, Some(&avatar), None);
|
|
|
|
|
assert_eq!(next["avatar"]["$type"], "blob");
|
|
|
|
|
assert_eq!(next["avatar"]["ref"]["$link"], "bafyavatar");
|
|
|
|
|
// Real mime_type from the blobs table — not hardcoded.
|
|
|
|
|
assert_eq!(next["avatar"]["mimeType"], "image/webp");
|
|
|
|
|
assert_eq!(next["avatar"]["size"], 999);
|
|
|
|
|
}
|
|
|
|
|
}
|