atproto/packages/bsky/tests/views/author-feed.test.ts
Samuel Newman a8c6b8997c
Disallow pinning someone else's post (#2840)
* Reapply "add failing test for pinning someone else's post"

This reverts commit 16a2399e19cd11851ae39edf06cb9fd812c28678.

* make sure pinned post belongs to the person who pinned it

* add snapshot

* Use uriToDid

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-10-01 11:53:19 -05:00

569 lines
16 KiB
TypeScript

import { AtpAgent, AppBskyActorProfile, AppBskyFeedDefs } from '@atproto/api'
import { TestNetwork, SeedClient, authorFeedSeed } from '@atproto/dev-env'
import {
forSnapshot,
paginateAll,
stripViewer,
stripViewerFromPost,
} from '../_util'
import { ReplyRef, isRecord } from '../../src/lexicon/types/app/bsky/feed/post'
import { isView as isEmbedRecordWithMedia } from '../../src/lexicon/types/app/bsky/embed/recordWithMedia'
import { isView as isImageEmbed } from '../../src/lexicon/types/app/bsky/embed/images'
import { isPostView } from '../../src/lexicon/types/app/bsky/feed/defs'
import { uriToDid } from '../../src/util/uris'
import { ids } from '../../src/lexicon/lexicons'
describe('pds author feed views', () => {
let network: TestNetwork
let agent: AtpAgent
let pdsAgent: AtpAgent
let sc: SeedClient
// account dids, for convenience
let alice: string
let bob: string
let carol: string
let dan: string
let eve: string
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'bsky_views_author_feed',
})
agent = network.bsky.getClient()
pdsAgent = network.pds.getClient()
sc = network.getSeedClient()
await authorFeedSeed(sc)
await network.processAll()
alice = sc.dids.alice
bob = sc.dids.bob
carol = sc.dids.carol
dan = sc.dids.dan
eve = sc.dids.eve
})
afterAll(async () => {
await network.close()
})
// @TODO(bsky) blocked by actor takedown via labels.
// @TODO(bsky) blocked by record takedown via labels.
it('fetches full author feeds for self (sorted, minimal viewer state).', async () => {
const aliceForAlice = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
expect(forSnapshot(aliceForAlice.data.feed)).toMatchSnapshot()
const bobForBob = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[bob].handle },
{
headers: await network.serviceHeaders(
bob,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
expect(forSnapshot(bobForBob.data.feed)).toMatchSnapshot()
const carolForCarol = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[carol].handle },
{
headers: await network.serviceHeaders(
carol,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
expect(forSnapshot(carolForCarol.data.feed)).toMatchSnapshot()
const danForDan = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[dan].handle },
{
headers: await network.serviceHeaders(
dan,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
expect(forSnapshot(danForDan.data.feed)).toMatchSnapshot()
})
it("reflects fetching user's state in the feed.", async () => {
const aliceForCarol = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle },
{
headers: await network.serviceHeaders(
carol,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
aliceForCarol.data.feed.forEach((postView) => {
const { viewer, uri } = postView.post
expect(viewer?.like).toEqual(sc.likes[carol][uri]?.toString())
expect(viewer?.repost).toEqual(sc.reposts[carol][uri]?.toString())
})
expect(forSnapshot(aliceForCarol.data.feed)).toMatchSnapshot()
})
it('paginates', async () => {
const results = (results) => results.flatMap((res) => res.feed)
const paginator = async (cursor?: string) => {
const res = await agent.api.app.bsky.feed.getAuthorFeed(
{
actor: sc.accounts[alice].handle,
cursor,
limit: 2,
},
{
headers: await network.serviceHeaders(
dan,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
return res.data
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.feed.length).toBeLessThanOrEqual(2),
)
const full = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle },
{
headers: await network.serviceHeaders(
dan,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
expect(full.data.feed.length).toEqual(4)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
it('fetches results unauthed.', async () => {
const { data: authed } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
const { data: unauthed } = await agent.api.app.bsky.feed.getAuthorFeed({
actor: sc.accounts[alice].handle,
})
expect(unauthed.feed.length).toBeGreaterThan(0)
expect(unauthed.feed).toEqual(
authed.feed.map((item) => {
const result = {
...item,
post: stripViewerFromPost(item.post),
}
if (item.reply) {
result.reply = {
parent: stripViewerFromPost(item.reply.parent),
root: stripViewerFromPost(item.reply.root),
grandparentAuthor:
item.reply.grandparentAuthor &&
stripViewer(item.reply.grandparentAuthor),
}
}
return result
}),
)
})
it('non-admins blocked by actor takedown.', async () => {
const { data: preBlock } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
{
headers: await network.serviceHeaders(
carol,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
expect(preBlock.feed.length).toBeGreaterThan(0)
await network.bsky.ctx.dataplane.takedownActor({
did: alice,
})
const attemptAsUser = agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
{
headers: await network.serviceHeaders(
carol,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
await expect(attemptAsUser).rejects.toThrow('Profile not found')
const attemptAsAdmin = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
{ headers: network.bsky.adminAuthHeaders() },
)
expect(attemptAsAdmin.data.feed.length).toEqual(preBlock.feed.length)
// Cleanup
await network.bsky.ctx.dataplane.untakedownActor({
did: alice,
})
})
it('blocked by record takedown.', async () => {
const { data: preBlock } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
{
headers: await network.serviceHeaders(
carol,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
expect(preBlock.feed.length).toBeGreaterThan(0)
const post = preBlock.feed[0].post
await network.bsky.ctx.dataplane.takedownRecord({
recordUri: post.uri,
})
const [{ data: postBlockAsUser }, { data: postBlockAsAdmin }] =
await Promise.all([
agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
{
headers: await network.serviceHeaders(
carol,
ids.AppBskyFeedGetAuthorFeed,
),
},
),
agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
{ headers: network.bsky.adminAuthHeaders() },
),
])
expect(postBlockAsUser.feed.length).toEqual(preBlock.feed.length - 1)
expect(postBlockAsUser.feed.map((item) => item.post.uri)).not.toContain(
post.uri,
)
expect(postBlockAsAdmin.feed.length).toEqual(preBlock.feed.length)
expect(postBlockAsAdmin.feed.map((item) => item.post.uri)).toContain(
post.uri,
)
// Cleanup
await network.bsky.ctx.dataplane.untakedownRecord({
recordUri: post.uri,
})
})
it('can filter by posts_with_media', async () => {
const { data: carolFeed } = await agent.api.app.bsky.feed.getAuthorFeed({
actor: carol,
filter: 'posts_with_media',
})
expect(carolFeed.feed.length).toBeGreaterThan(0)
expect(
carolFeed.feed.every(({ post }) => {
const isRecordWithActorMedia =
isEmbedRecordWithMedia(post.embed) && isImageEmbed(post.embed?.media)
const isActorMedia = isImageEmbed(post.embed)
const isFromActor = post.author.did === carol
return (isRecordWithActorMedia || isActorMedia) && isFromActor
}),
).toBeTruthy()
const { data: bobFeed } = await agent.api.app.bsky.feed.getAuthorFeed({
actor: bob,
filter: 'posts_with_media',
})
expect(
bobFeed.feed.every(({ post }) => {
return isImageEmbed(post.embed) && post.author.did === bob
}),
).toBeTruthy()
const { data: danFeed } = await agent.api.app.bsky.feed.getAuthorFeed({
actor: dan,
filter: 'posts_with_media',
})
expect(danFeed.feed.length).toEqual(0)
})
it('filters by posts_no_replies', async () => {
const { data: carolFeed } = await agent.api.app.bsky.feed.getAuthorFeed({
actor: carol,
filter: 'posts_no_replies',
})
expect(
carolFeed.feed.every(({ post }) => {
return (
(isRecord(post.record) && !post.record.reply) ||
(isRecord(post.record) && post.record.reply)
)
}),
).toBeTruthy()
const { data: danFeed } = await agent.api.app.bsky.feed.getAuthorFeed({
actor: dan,
filter: 'posts_no_replies',
})
expect(
danFeed.feed.every(({ post }) => {
return (
(isRecord(post.record) && !post.record.reply) ||
(isRecord(post.record) && post.record.reply)
)
}),
).toBeTruthy()
})
it('posts_and_author_threads includes self-replies', async () => {
const { data: eveFeed } = await agent.api.app.bsky.feed.getAuthorFeed({
actor: eve,
filter: 'posts_and_author_threads',
})
expect(eveFeed.feed.length).toEqual(6)
expect(
eveFeed.feed.some(({ post }) => {
const replyByEve =
isRecord(post.record) && post.record.reply && post.author.did === eve
return replyByEve
}),
).toBeTruthy()
// does not include eve's replies to fred, even within her own thread.
expect(
eveFeed.feed.every(({ post, reply }) => {
if (!post || !isRecord(post.record) || !post.record.reply) {
return true // not a reply
}
const replyToEve = isReplyTo(post.record.reply, eve)
const replyToReplyByEve =
reply &&
isPostView(reply.parent) &&
isRecord(reply.parent.record) &&
(!reply.parent.record.reply ||
isReplyTo(reply.parent.record.reply, eve))
return replyToEve && replyToReplyByEve
}),
).toBeTruthy()
// reposts are preserved
expect(
eveFeed.feed.some(({ post, reason }) => {
const repostOfOther =
reason && isRecord(post.record) && post.author.did !== eve
return repostOfOther
}),
).toBeTruthy()
})
describe('pins', () => {
async function createAndPinPost() {
const post = await sc.post(alice, 'pinned post')
await network.processAll()
const profile = await pdsAgent.com.atproto.repo.getRecord({
repo: alice,
collection: 'app.bsky.actor.profile',
rkey: 'self',
})
if (!AppBskyActorProfile.isRecord(profile.data.value)) {
throw new Error('')
}
const newProfile: AppBskyActorProfile.Record = {
...profile,
pinnedPost: {
uri: post.ref.uriStr,
cid: post.ref.cid.toString(),
},
}
await sc.updateProfile(alice, newProfile)
await network.processAll()
return post
}
it('params.includePins = true, pin is in first page of results', async () => {
await sc.post(alice, 'not pinned post')
const post = await createAndPinPost()
await sc.post(alice, 'not pinned post')
const { data } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle, includePins: true },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
const pinnedPosts = data.feed.filter(
(item) => item.post.uri === post.ref.uriStr,
)
expect(pinnedPosts.length).toEqual(1)
const pinnedPost = data.feed.at(0)
expect(pinnedPost?.post?.uri).toEqual(post.ref.uriStr)
expect(pinnedPost?.post?.viewer?.pinned).toBeTruthy()
expect(AppBskyFeedDefs.isReasonPin(pinnedPost?.reason)).toBeTruthy()
const notPinnedPost = data.feed.at(1)
expect(notPinnedPost?.post?.viewer?.pinned).toBeFalsy()
expect(forSnapshot(data.feed)).toMatchSnapshot()
})
it('params.includePins = true, pin is NOT in first page of results', async () => {
const post = await createAndPinPost()
await sc.post(alice, 'not pinned post')
await sc.post(alice, 'not pinned post')
await network.processAll()
const { data: page1 } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle, includePins: true, limit: 2 },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
// exists with `reason`
const pinnedPost = page1.feed.find(
(item) => item.post.uri === post.ref.uriStr,
)
expect(pinnedPost?.post?.uri).toEqual(post.ref.uriStr)
expect(pinnedPost?.post?.viewer?.pinned).toBeTruthy()
expect(AppBskyFeedDefs.isReasonPin(pinnedPost?.reason)).toBeTruthy()
expect(forSnapshot(page1.feed)).toMatchSnapshot()
const { data: page2 } = await agent.api.app.bsky.feed.getAuthorFeed(
{
actor: sc.accounts[alice].handle,
includePins: true,
cursor: page1.cursor,
},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
// exists without `reason`
const laterPinnedPost = page2.feed.find(
(item) => item.post.uri === post.ref.uriStr,
)
expect(laterPinnedPost?.post?.uri).toEqual(post.ref.uriStr)
expect(laterPinnedPost?.post?.viewer?.pinned).toBeTruthy()
expect(AppBskyFeedDefs.isReasonPin(laterPinnedPost?.reason)).toBeFalsy()
expect(forSnapshot(page2.feed)).toMatchSnapshot()
})
it('params.includePins = false', async () => {
const post = await createAndPinPost()
const { data } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
// exists without `reason`
const pinnedPost = data.feed.find(
(item) => item.post.uri === post.ref.uriStr,
)
expect(AppBskyFeedDefs.isReasonPin(pinnedPost?.reason)).toBeFalsy()
expect(forSnapshot(data.feed)).toMatchSnapshot()
})
it("cannot pin someone else's post", async () => {
const bobPost = await sc.post(bob, 'pinned post')
await sc.post(alice, 'not pinned post')
await network.processAll()
const profile = await pdsAgent.com.atproto.repo.getRecord({
repo: alice,
collection: 'app.bsky.actor.profile',
rkey: 'self',
})
if (!AppBskyActorProfile.isRecord(profile.data.value)) {
throw new Error('')
}
const newProfile: AppBskyActorProfile.Record = {
...profile,
pinnedPost: {
uri: bobPost.ref.uriStr,
cid: bobPost.ref.cid.toString(),
},
}
await sc.updateProfile(alice, newProfile)
await network.processAll()
const { data } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: sc.accounts[alice].handle },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyFeedGetAuthorFeed,
),
},
)
const pinnedPost = data.feed.find(
(item) => item.post.uri === bobPost.ref.uriStr,
)
expect(pinnedPost).toBeUndefined()
expect(forSnapshot(data.feed)).toMatchSnapshot()
})
})
})
function isReplyTo(reply: ReplyRef, did: string) {
return uriToDid(reply.root.uri) === did && uriToDid(reply.parent.uri) === did
}