atproto/packages/bsky/tests/views/thread-v2.test.ts

2112 lines
64 KiB
TypeScript

import assert from 'node:assert'
import { AppBskyUnspeccedDefs, AtpAgent } from '@atproto/api'
import { SeedClient, TestNetwork, seedThreadV2 } from '@atproto/dev-env'
import { ids } from '../../src/lexicon/lexicons'
import { ThreadItemPost } from '../../src/lexicon/types/app/bsky/unspecced/defs'
import { OutputSchema as OutputSchemaHiddenThread } from '../../src/lexicon/types/app/bsky/unspecced/getPostThreadOtherV2'
import {
OutputSchema as OutputSchemaThread,
QueryParams as QueryParamsThread,
} from '../../src/lexicon/types/app/bsky/unspecced/getPostThreadV2'
import {
ThreadItemValuePost,
ThreadOtherItemValuePost,
} from '../../src/views/threads-v2'
import { forSnapshot } from '../_util'
type PostProps = Pick<ThreadItemPost, 'moreReplies' | 'opThread'>
const props = (overrides: Partial<PostProps> = {}): PostProps => ({
moreReplies: 0,
opThread: false,
...overrides,
})
type PostPropsHidden = Pick<
ThreadItemPost,
'hiddenByThreadgate' | 'mutedByViewer'
>
const propsHidden = (
overrides: Partial<PostPropsHidden> = {},
): PostPropsHidden => ({
hiddenByThreadgate: false,
mutedByViewer: false,
...overrides,
})
describe('appview thread views v2', () => {
let network: TestNetwork
let agent: AtpAgent
let labelerDid: string
let sc: SeedClient<TestNetwork>
beforeAll(async () => {
network = await TestNetwork.create({
bsky: {
maxThreadParents: 15,
threadTagsBumpDown: new Set([seedThreadV2.TAG_BUMP_DOWN]),
threadTagsHide: new Set([seedThreadV2.TAG_HIDE]),
},
dbPostgresSchema: 'bsky_views_thread_v_two',
})
agent = network.bsky.getClient()
sc = network.getSeedClient()
labelerDid = network.bsky.ctx.cfg.modServiceDid
await network.processAll()
})
afterAll(async () => {
await network.close()
})
describe('not found anchor', () => {
it('returns not found error', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: 'at://did:plc:123/app.bsky.feed.post/456',
})
const { thread: t } = data
expect(t).toEqual([
expect.objectContaining({
depth: 0,
value: {
$type: 'app.bsky.unspecced.defs#threadItemNotFound',
},
}),
])
})
})
describe('simple thread', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.simple>>
beforeAll(async () => {
seed = await seedThreadV2.simple(sc)
await network.processAll()
})
it('returns thread anchored on root', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ depth: 0, uri: seed.root.ref.uriStr }),
expect.objectContaining({ depth: 1, uri: seed.r['0'].ref.uriStr }),
expect.objectContaining({ depth: 2, uri: seed.r['0.0'].ref.uriStr }),
expect.objectContaining({ depth: 1, uri: seed.r['1'].ref.uriStr }),
expect.objectContaining({ depth: 1, uri: seed.r['2'].ref.uriStr }),
expect.objectContaining({ depth: 2, uri: seed.r['2.0'].ref.uriStr }),
expect.objectContaining({ depth: 1, uri: seed.r['3'].ref.uriStr }),
])
expect(forSnapshot(data)).toMatchSnapshot()
})
it('returns thread anchored on r 0', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['0'].ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ depth: -1, uri: seed.root.ref.uriStr }),
expect.objectContaining({ depth: 0, uri: seed.r['0'].ref.uriStr }),
expect.objectContaining({ depth: 1, uri: seed.r['0.0'].ref.uriStr }),
])
expect(forSnapshot(data)).toMatchSnapshot()
})
it('returns thread anchored on r 0.0', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['0.0'].ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ depth: -2, uri: seed.root.ref.uriStr }),
expect.objectContaining({ depth: -1, uri: seed.r['0'].ref.uriStr }),
expect.objectContaining({ depth: 0, uri: seed.r['0.0'].ref.uriStr }),
])
expect(forSnapshot(data)).toMatchSnapshot()
})
it('returns thread anchored on 1', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['1'].ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ depth: -1, uri: seed.root.ref.uriStr }),
expect.objectContaining({ depth: 0, uri: seed.r['1'].ref.uriStr }),
])
expect(forSnapshot(data)).toMatchSnapshot()
})
it('returns thread anchored on 2', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['2'].ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ depth: -1, uri: seed.root.ref.uriStr }),
expect.objectContaining({ depth: 0, uri: seed.r['2'].ref.uriStr }),
expect.objectContaining({ depth: 1, uri: seed.r['2.0'].ref.uriStr }),
])
expect(forSnapshot(data)).toMatchSnapshot()
})
it('returns thread anchored on 2.0', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['2.0'].ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ depth: -2, uri: seed.root.ref.uriStr }),
expect.objectContaining({ depth: -1, uri: seed.r['2'].ref.uriStr }),
expect.objectContaining({ depth: 0, uri: seed.r['2.0'].ref.uriStr }),
])
expect(forSnapshot(data)).toMatchSnapshot()
})
it('returns thread anchored on 3', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['3'].ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ depth: -1, uri: seed.root.ref.uriStr }),
expect.objectContaining({ depth: 0, uri: seed.r['3'].ref.uriStr }),
])
expect(forSnapshot(data)).toMatchSnapshot()
})
})
describe('long thread', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.long>>
beforeAll(async () => {
seed = await seedThreadV2.long(sc)
await network.processAll()
})
describe('calculating depth', () => {
type Case = {
postKey: string
}
const cases: Case[] = [
{ postKey: 'root' },
{ postKey: '0' },
{ postKey: '0.0' },
{ postKey: '0.0.0' },
{ postKey: '0.0.0.0' },
{ postKey: '0.0.0.0.0' },
{ postKey: '0.0.1' },
{ postKey: '1' },
{ postKey: '2' },
{ postKey: '3' },
{ postKey: '4' },
{ postKey: '4.0' },
{ postKey: '4.0.0' },
{ postKey: '4.0.0.0' },
{ postKey: '4.0.0.0.0' },
{ postKey: '5' },
{ postKey: '6' },
{ postKey: '7' },
]
it.each(cases)(
'calculates the depths anchored at $postKey',
async ({ postKey }) => {
const post = postKey === 'root' ? seed.root : seed.r[postKey]
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: post.ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
const anchorIndex = t.findIndex((i) => i.uri === post.ref.uriStr)
const anchorPost = t[anchorIndex]
const parents = t.slice(0, anchorIndex)
const children = t.slice(anchorIndex + 1, t.length)
parents.forEach((parent) => {
expect(parent.depth).toBeLessThan(0)
})
expect(anchorPost.depth).toEqual(0)
children.forEach((child) => {
expect(child.depth).toBeGreaterThan(0)
})
},
)
})
})
describe('deep thread', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.deep>>
beforeAll(async () => {
seed = await seedThreadV2.deep(sc)
await network.processAll()
})
describe('above', () => {
it('returns the ancestors above if true (default)', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.r['0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0'].ref.uriStr,
},
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toHaveLength(16) // anchor + 15 ancestors, as limited by `maxThreadParents`.
const first = t.at(0)
expect(first!.uri).toBe(seed.r['0.0.0'].ref.uriStr)
expect(first!.value.moreParents).toBe(true)
const second = t.at(1)
expect(second!.uri).toBe(seed.r['0.0.0.0'].ref.uriStr)
expect(second!.value.moreParents).toBe(false)
const last = t.at(-1)
expect(last!.uri).toBe(
seed.r['0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0'].ref.uriStr,
)
expect(last!.value.moreParents).toBe(false)
})
it(`does not return ancestors if false`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.r['0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0'].ref.uriStr,
above: false,
},
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toHaveLength(1)
const first = t.at(0)
expect(first!.uri).toBe(
seed.r['0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0'].ref.uriStr,
)
})
})
describe('below', () => {
it('limits to the below count', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
below: 10,
},
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toHaveLength(11)
const first = t.at(0)
expect(first!.uri).toBe(seed.root.ref.uriStr)
})
it(`does not fulfill the below count if there are not enough items in the thread`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.r['0.0.0.0.0.0.0.0.0.0.0.0.0.0.0'].ref.uriStr,
above: false,
below: 10,
},
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toHaveLength(4)
const first = t.at(0)
expect(first!.uri).toBe(
seed.r['0.0.0.0.0.0.0.0.0.0.0.0.0.0.0'].ref.uriStr,
)
})
})
})
describe('branching factor', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.branchingFactor>>
beforeAll(async () => {
seed = await seedThreadV2.branchingFactor(sc)
await network.processAll()
})
type Case =
| {
branchingFactor: number
sort: QueryParamsThread['sort']
postKeys: string[]
}
| {
branchingFactor: number
sort: QueryParamsThread['sort']
// For higher branching factors it gets too verbose to write all posts.
length: number
}
const cases: Case[] = [
{
branchingFactor: 1,
sort: 'oldest',
postKeys: [
'root',
'0',
'0.0',
'0.0.0',
'1',
'1.0',
'1.0.0',
'2',
'2.0',
'2.0.0',
'3',
'3.0',
'3.0.0',
],
},
{
branchingFactor: 1,
sort: 'newest',
postKeys: [
'root',
'3',
'3.3',
'3.3.3',
'2',
'2.3',
'2.3.3',
'1',
'1.3',
'1.3.3',
'0',
'0.3',
'0.3.3',
],
},
{
branchingFactor: 2,
sort: 'oldest',
postKeys: [
'root',
'0',
'0.0',
'0.0.0',
'0.0.1',
'0.1',
'0.1.0',
'0.1.1',
'1',
'1.0',
'1.0.0',
'1.1',
'1.1.0',
'1.1.1',
'2',
'2.0',
'2.0.0',
'2.0.1',
'2.1',
'2.1.0',
'2.1.1',
'3',
'3.0',
'3.0.0',
'3.0.1',
'3.1',
'3.1.0',
'3.1.1',
],
},
{
branchingFactor: 2,
sort: 'newest',
postKeys: [
'root',
'3',
'3.3',
'3.3.3',
'3.3.2',
'3.2',
'3.2.3',
'3.2.2',
'2',
'2.3',
'2.3.3',
'2.3.2',
'2.2',
'2.2.3',
'2.2.2',
'1',
'1.3',
'1.3.3',
'1.3.2',
'1.2',
'1.2.3',
'1.2.2',
'0',
'0.3',
'0.3.3',
'0.3.2',
'0.2',
'0.2.3',
'0.2.2',
],
},
{
branchingFactor: 3,
sort: 'newest',
length: 53,
},
{
branchingFactor: 4,
sort: 'newest',
length: 82,
},
{
branchingFactor: 5,
sort: 'newest',
// The seeds have 1 post with 5 replies, so it is +1 compared to branchingFactor 4.
length: 83,
},
]
it.each(cases)(
'returns all top-level replies and limits nested to branching factor of $branchingFactor when sorting by $sort',
async (args) => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
sort: 'sort' in args ? args.sort : undefined,
branchingFactor: args.branchingFactor,
},
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
if ('length' in args) {
expect(data.thread).toHaveLength(args.length)
} else {
const tUris = t.map((i) => i.uri)
const postUris = args.postKeys.map((k) =>
k === 'root' ? seed.root.ref.uriStr : seed.r[k].ref.uriStr,
)
expect(tUris).toEqual(postUris)
}
},
)
})
describe('annotate more replies', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.annotateMoreReplies>>
beforeAll(async () => {
seed = await seedThreadV2.annotateMoreReplies(sc)
await network.processAll()
})
it('annotates correctly both in cases of trimmed replies by depth and by branching factor reached', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
below: 4,
branchingFactor: 2,
},
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
value: expect.objectContaining(props({ opThread: true })),
}),
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['0.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['0.0.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['0.0.0.0'].ref.uriStr,
value: expect.objectContaining(props({ moreReplies: 5 })),
}),
expect.objectContaining({
uri: seed.r['0.1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['0.1.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['0.1.0.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
value: expect.objectContaining(props({ moreReplies: 1 })),
}),
expect.objectContaining({
uri: seed.r['1.0'].ref.uriStr,
value: expect.objectContaining(props({ moreReplies: 3 })),
}),
expect.objectContaining({
uri: seed.r['1.0.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['1.0.1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['1.1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['1.1.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['1.1.1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
])
})
})
describe(`annotate OP thread`, () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.annotateOP>>
beforeAll(async () => {
seed = await seedThreadV2.annotateOP(sc)
await network.processAll()
})
type Case = {
postKey: string
length: number
opThreadPostKeys: string[]
}
const cases: Case[] = [
{
postKey: 'root',
length: 9,
opThreadPostKeys: ['root', '0', '0.0', '0.0.0', '2'],
},
{
postKey: '0',
length: 4,
opThreadPostKeys: ['root', '0', '0.0', '0.0.0'],
},
{
postKey: '0.0',
length: 4,
opThreadPostKeys: ['root', '0', '0.0', '0.0.0'],
},
{
postKey: '0.0.0',
length: 4,
opThreadPostKeys: ['root', '0', '0.0', '0.0.0'],
},
{
postKey: '1',
length: 3,
opThreadPostKeys: ['root'],
},
{
postKey: '1.0',
length: 3,
opThreadPostKeys: ['root'],
},
{
postKey: '2',
length: 4,
opThreadPostKeys: ['root', '2'],
},
{
postKey: '2.0',
length: 4,
opThreadPostKeys: ['root', '2'],
},
{
postKey: '2.0.0',
length: 4,
opThreadPostKeys: ['root', '2'],
},
]
it.each(cases)(
`annotates OP threads correctly anchored at $postKey`,
async ({ postKey, length, opThreadPostKeys: opThreadPosts }) => {
const post = postKey === 'root' ? seed.root : seed.r[postKey]
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: post.ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
const opThreadPostsUris = new Set(
opThreadPosts.map((k) =>
k === 'root' ? seed.root.ref.uriStr : seed.r[k].ref.uriStr,
),
)
expect(t).toHaveLength(length)
t.forEach((i) => {
expect(i.value.opThread).toBe(opThreadPostsUris.has(i.uri))
})
},
)
})
describe('bumping and sorting', () => {
describe('sorting', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.sort>>
beforeAll(async () => {
seed = await seedThreadV2.sort(sc)
await network.processAll()
})
type Case = {
sort: QueryParamsThread['sort']
postKeys: string[]
}
const cases: Case[] = [
{
sort: 'newest',
postKeys: [
'root',
'2',
'2.2',
'2.1',
'2.0',
'1',
'1.2',
'1.1',
'1.0',
'0',
'0.2',
'0.1',
'0.0',
],
},
{
sort: 'oldest',
postKeys: [
'root',
'0',
'0.0',
'0.1',
'0.2',
'1',
'1.0',
'1.1',
'1.2',
'2',
'2.0',
'2.1',
'2.2',
],
},
{
sort: 'top',
postKeys: [
'root',
'1',
'1.1',
'1.0',
'1.2',
'2',
'2.0',
'2.1',
'2.2',
'0',
'0.2',
'0.1',
'0.0',
],
},
]
it.each(cases)(
'sorts by $sort in all levels',
async ({ sort: sort, postKeys }) => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
sort,
},
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
const tUris = t.map((i) => i.uri)
const postUris = postKeys.map((k) =>
k === 'root' ? seed.root.ref.uriStr : seed.r[k].ref.uriStr,
)
expect(tUris).toEqual(postUris)
},
)
})
describe('bumping', () => {
describe('sorting within bumped post groups', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.bumpGroupSorting>>
beforeAll(async () => {
seed = await seedThreadV2.bumpGroupSorting(sc)
await network.processAll()
})
type Case = {
sort: QueryParamsThread['sort']
postKeys: string[]
}
const cases: Case[] = [
{
sort: 'newest',
postKeys: ['root', '5', '3', '1', '7', '4', '0', '6', '2'],
},
{
sort: 'oldest',
postKeys: ['root', '1', '3', '5', '0', '4', '7', '2', '6'],
},
]
it.each(cases)(
'sorts by $sort inside each bumped group',
async ({ sort: sort, postKeys }) => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
sort,
},
{
headers: await network.serviceHeaders(
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
const tUris = t.map((i) => i.uri)
const postUris = postKeys.map((k) =>
k === 'root' ? seed.root.ref.uriStr : seed.r[k].ref.uriStr,
)
expect(tUris).toEqual(postUris)
},
)
})
describe('OP and viewer', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.bumpOpAndViewer>>
beforeAll(async () => {
seed = await seedThreadV2.bumpOpAndViewer(sc)
await network.processAll()
})
type Case = {
sort: QueryParamsThread['sort']
postKeys: string[]
}
const cases: Case[] = [
{
sort: 'newest',
postKeys: [
'root',
'3', // op
'3.2', // op
'3.0', // viewer
'3.4',
'3.3',
'3.1',
'4', // viewer
'4.2', // op
'4.3', // viewer
'4.4',
'4.1',
'4.0',
'2',
'2.2', // op
'2.0', // viewer
'2.4',
'2.3',
'2.1',
'1',
'1.2', // op
'1.3', // viewer
'1.4',
'1.1',
'1.0',
'0',
'0.4', // op
'0.3', // viewer
'0.2',
'0.1',
'0.0',
],
},
{
sort: 'oldest',
postKeys: [
'root',
'3', // op
'3.2', // op
'3.0', // viewer
'3.1',
'3.3',
'3.4',
'4', // viewer
'4.2', // op
'4.3', // viewer
'4.0',
'4.1',
'4.4',
'0',
'0.4', // op
'0.3', // viewer
'0.0',
'0.1',
'0.2',
'1',
'1.2', // op
'1.3', // viewer
'1.0',
'1.1',
'1.4',
'2',
'2.2', // op
'2.0', // viewer
'2.1',
'2.3',
'2.4',
],
},
{
sort: 'top',
postKeys: [
'root',
'3', // op
'3.2', // op
'3.0', // viewer
'3.4',
'3.3',
'3.1',
'4', // viewer
'4.2', // op
'4.3', // viewer
'4.1',
'4.0',
'4.4',
'1',
'1.2', // op
'1.3', // viewer
'1.1',
'1.0',
'1.4',
'2',
'2.2', // op
'2.0', // viewer
'2.1',
'2.4',
'2.3',
'0',
'0.4', // op
'0.3', // viewer
'0.2',
'0.1',
'0.0',
],
},
]
it.each(cases)(
'bumps up OP and viewer and sorts by $sort in all levels',
async ({ sort: sort, postKeys }) => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
sort,
},
{
headers: await network.serviceHeaders(
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
const tUris = t.map((i) => i.uri)
const postUris = postKeys.map((k) =>
k === 'root' ? seed.root.ref.uriStr : seed.r[k].ref.uriStr,
)
expect(tUris).toEqual(postUris)
},
)
})
describe('followers', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.bumpFollows>>
beforeAll(async () => {
seed = await seedThreadV2.bumpFollows(sc)
await network.processAll()
})
const threadForPostAndViewer = async (
post: string,
viewer: string,
prioritizeFollowedUsers: boolean = false,
) => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: post,
sort: 'newest',
prioritizeFollowedUsers,
},
{
headers: await network.serviceHeaders(
viewer,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
return t
}
it('bumps up followed users if option is set', async () => {
const prioritizeFollowedUsers = true
const t1 = await threadForPostAndViewer(
seed.root.ref.uriStr,
seed.users.viewerF.did,
prioritizeFollowedUsers,
)
expect(t1).toEqual([
expect.objectContaining({ uri: seed.root.ref.uriStr }), // root
expect.objectContaining({ uri: seed.r['3'].ref.uriStr }), // op reply
expect.objectContaining({ uri: seed.r['4'].ref.uriStr }), // viewer reply
expect.objectContaining({ uri: seed.r['1'].ref.uriStr }), // newest followed reply
expect.objectContaining({ uri: seed.r['0'].ref.uriStr }), // oldest followed reply
expect.objectContaining({ uri: seed.r['5'].ref.uriStr }), // newest non-followed reply
expect.objectContaining({ uri: seed.r['2'].ref.uriStr }), // oldest non-followed reply
])
const t2 = await threadForPostAndViewer(
seed.root.ref.uriStr,
seed.users.viewerNoF.did,
prioritizeFollowedUsers,
)
expect(t2).toEqual([
expect.objectContaining({ uri: seed.root.ref.uriStr }), // root
expect.objectContaining({ uri: seed.r['3'].ref.uriStr }), // op reply
expect.objectContaining({ uri: seed.r['5'].ref.uriStr }), // viewer reply
// newest to oldest
expect.objectContaining({ uri: seed.r['4'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['2'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['1'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['0'].ref.uriStr }),
])
})
it('does not prioritize followed users if option is not set', async () => {
const t1 = await threadForPostAndViewer(
seed.root.ref.uriStr,
seed.users.viewerF.did,
)
expect(t1).toHaveLength(7)
expect(t1[0].uri).toBe(seed.root.ref.uriStr) // root
expect(t1[1].uri).toBe(seed.r['3'].ref.uriStr) // op reply
expect(t1[2].uri).toBe(seed.r['4'].ref.uriStr) // viewer reply
// newest to oldest
expect(t1[3].uri).toBe(seed.r['5'].ref.uriStr)
expect(t1[4].uri).toBe(seed.r['2'].ref.uriStr)
expect(t1[5].uri).toBe(seed.r['1'].ref.uriStr)
expect(t1[6].uri).toBe(seed.r['0'].ref.uriStr)
const t2 = await threadForPostAndViewer(
seed.root.ref.uriStr,
seed.users.viewerNoF.did,
)
expect(t2).toHaveLength(7)
expect(t2[0].uri).toBe(seed.root.ref.uriStr) // root
expect(t2[1].uri).toBe(seed.r['3'].ref.uriStr) // op reply
expect(t2[2].uri).toBe(seed.r['5'].ref.uriStr) // viewer reply
// newest to oldest
expect(t2[3].uri).toBe(seed.r['4'].ref.uriStr)
expect(t2[4].uri).toBe(seed.r['2'].ref.uriStr)
expect(t2[5].uri).toBe(seed.r['1'].ref.uriStr)
expect(t2[6].uri).toBe(seed.r['0'].ref.uriStr)
})
})
})
})
describe(`blocks, deletions, no-unauthenticated`, () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.blockDeletionAuth>>
beforeAll(async () => {
seed = await seedThreadV2.blockDeletionAuth(sc, labelerDid)
await network.processAll()
})
describe(`1p blocks`, () => {
it(`blocked reply is omitted from replies`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// Use `blocked`, who was blocked by `blocker`, author of '0'.
seed.users.blocked.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
assertPosts(t)
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({ uri: seed.root.ref.uriStr }),
expect.objectContaining({ uri: seed.r['3'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3.0.0'].ref.uriStr }),
])
})
it(`blocked anchor returns lone blocked view`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['0'].ref.uriStr },
{
headers: await network.serviceHeaders(
// Use `blocked`, who was blocked by `blocker`, author of '0'.
seed.users.blocked.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemBlocked',
}),
}),
])
})
it(`blocked parent is replaced by blocked view`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['0.0'].ref.uriStr },
{
headers: await network.serviceHeaders(
// Use `blocked`, who was blocked by `blocker`, author of '0'.
seed.users.blocked.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
depth: -1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemBlocked',
}),
}),
expect.objectContaining({
uri: seed.r['0.0'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
])
})
})
describe(`3p blocks`, () => {
it(`blocked reply is omitted from replies`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// Use `alice` who is a 3rd party between `op` and `opBlocked`.
seed.users.alice.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({ uri: seed.root.ref.uriStr }),
expect.objectContaining({ uri: seed.r['0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['0.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3.0.0'].ref.uriStr }),
])
})
it(`blocked anchor returns post with blocked parent and non-blocked descendants`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['1'].ref.uriStr },
{
headers: await network.serviceHeaders(
// Use `alice` who is a 3rd party between `op` and `opBlocked`.
seed.users.alice.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
depth: -1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemBlocked',
}),
}),
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
// 1.0 is blocked, but 1.1 is not
expect.objectContaining({
uri: seed.r['1.1'].ref.uriStr,
depth: 1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
])
})
it(`blocked parent is replaced by blocked view`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['1.0'].ref.uriStr },
{
headers: await network.serviceHeaders(
// Use `alice` who is a 3rd party between `op` and `opBlocked`.
seed.users.alice.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
depth: -1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemBlocked',
}),
}),
expect.objectContaining({
uri: seed.r['1.0'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
])
})
it(`blocked root is replaced by blocked view`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['1.1'].ref.uriStr },
{
headers: await network.serviceHeaders(
// Use `alice` who is a 3rd party between `op` and `opBlocked`.
seed.users.alice.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
depth: -2,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemBlocked',
}),
}),
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
depth: -1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
expect.objectContaining({
uri: seed.r['1.1'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
])
})
})
describe(`deleted posts`, () => {
it(`deleted reply is omitted from replies`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({ uri: seed.root.ref.uriStr }),
expect.objectContaining({ uri: seed.r['0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['0.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['3.0.0'].ref.uriStr }),
])
})
it(`deleted parent is replaced by not found view`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['2.0'].ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['2'].ref.uriStr,
depth: -1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemNotFound',
}),
}),
expect.objectContaining({
uri: seed.r['2.0'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
])
})
})
describe('no-unauthenticated', () => {
it(`no-unauthenticated reply is omitted from replies`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: {
'atproto-accept-labelers': `${labelerDid}`,
},
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
depth: 1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
expect.objectContaining({
uri: seed.r['0.0'].ref.uriStr,
depth: 2,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
])
})
it(`no-unauthenticated anchor returns no-unauthenticated view without breaking the parent chain`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['3'].ref.uriStr },
{
headers: {
'atproto-accept-labelers': `${labelerDid}`,
},
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
depth: -1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
expect.objectContaining({
uri: seed.r['3'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemNoUnauthenticated',
}),
}),
])
})
it(`no-unauthenticated parent is replaced by no-unauthenticated view without breaking the parent chain`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.r['3.0.0'].ref.uriStr },
{
headers: {
'atproto-accept-labelers': `${labelerDid}`,
},
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
depth: -3,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
expect.objectContaining({
uri: seed.r['3'].ref.uriStr,
depth: -2,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemNoUnauthenticated',
}),
}),
expect.objectContaining({
uri: seed.r['3.0'].ref.uriStr,
depth: -1,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemNoUnauthenticated',
}),
}),
expect.objectContaining({
uri: seed.r['3.0.0'].ref.uriStr,
depth: 0,
value: expect.objectContaining({
$type: 'app.bsky.unspecced.defs#threadItemPost',
}),
}),
])
})
})
})
describe(`mutes`, () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.mutes>>
beforeAll(async () => {
seed = await seedThreadV2.mutes(sc)
await network.processAll()
})
describe('omitting muted replies', () => {
it(`muted reply is omitted in top-level replies and in nested replies`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// Fetching as `op` mutes `opMuted`.
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(true)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
value: expect.objectContaining(props({ opThread: true })),
}),
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// 1.0 is a nested muted reply, so it is omitted.
expect.objectContaining({
uri: seed.r['1.1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
])
})
it(`top-level muted replies are returned when fetching hidden, sorted by newest`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadOtherV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// Fetching as `op` mutes `opMuted`.
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadOtherV2,
),
},
)
const { thread: t } = data
assertHiddenPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
value: expect.objectContaining(
propsHidden({ mutedByViewer: true }),
),
}),
// No nested replies for hidden.
])
})
})
describe('OP mutes', () => {
it(`mutes by OP don't mute for 3p`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// Fetching as `muter` mutes `muted`.
seed.users.muter.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(true)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
value: expect.objectContaining(props({ opThread: true })),
}),
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['0.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// 0.1 is a nested muted reply, so it is omitted.
])
})
it(`fetches hidden replies includes own mutes, not OP mutes, sorted by newest`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadOtherV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// Fetching as `muter` mutes `muted`.
seed.users.muter.did,
ids.AppBskyUnspeccedGetPostThreadOtherV2,
),
},
)
const { thread: t } = data
assertHiddenPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
value: expect.objectContaining(
propsHidden({ mutedByViewer: true }),
),
}),
// No nested replies for hidden.
])
})
it(`mutes by OP don't affect the muted user`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.opMuted.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
assertPosts(t)
// No muted posts by `opMuted`, gets the full thread.
expect(t.length).toBe(1 + Object.keys(seed.r).length) // root + replies
})
})
})
describe(`threadgated`, () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.threadgated>>
beforeAll(async () => {
seed = await seedThreadV2.threadgated(sc)
await network.processAll()
})
it(`threadgated reply is omitted in top-level replies and in nested replies`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(true)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
value: expect.objectContaining(props({ opThread: true })),
}),
expect.objectContaining({
uri: seed.r['2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// OP reply bumped up.
expect.objectContaining({
uri: seed.r['2.2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['2.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// 2.1 is a nested hidden reply, so it is omitted.
])
})
it(`top-level threadgated replies are returned to OP when fetching hidden, sorted by newest`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadOtherV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
seed.users.op.did,
ids.AppBskyUnspeccedGetPostThreadOtherV2,
),
},
)
const { thread: t } = data
assertHiddenPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
value: expect.objectContaining(
propsHidden({ hiddenByThreadgate: true }),
),
}),
// No nested replies for hidden.
// Mutes come after hidden.
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
value: expect.objectContaining(propsHidden({ mutedByViewer: true })),
}),
])
})
it(`author of hidden reply does not see it as hidden`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// `alice` does not get its own reply as hidden.
seed.users.alice.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(false)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
value: expect.objectContaining(props({ opThread: true })),
}),
// alice does not see its own reply as hidden.
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// OP reply bumped up.
expect.objectContaining({
uri: seed.r['1.2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['1.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['1.1'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// `opMuted` is not muted by `alice`.
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// OP reply bumped up.
expect.objectContaining({
uri: seed.r['2.2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['2.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// 2.1 is a nested hidden reply, so it is omitted.
])
})
it(`other viewers are affected by threadgate-hidden replies by OP`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// `viewer` also gets the replies as hidden.
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(true)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.root.ref.uriStr,
value: expect.objectContaining(props({ opThread: true })),
}),
// `opMuted` doesn't see itself as muted, just `op` does.
expect.objectContaining({
uri: seed.r['0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// OP reply bumped up.
expect.objectContaining({
uri: seed.r['2.2'].ref.uriStr,
value: expect.objectContaining(props()),
}),
expect.objectContaining({
uri: seed.r['2.0'].ref.uriStr,
value: expect.objectContaining(props()),
}),
// 2.1 is a nested hidden reply, so it is omitted.
])
})
it(`top-level threadgated replies are returned to other viewers when fetching hidden, sorted by newest`, async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadOtherV2(
{ anchor: seed.root.ref.uriStr },
{
headers: await network.serviceHeaders(
// `viewer` also gets the replies as hidden.
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadOtherV2,
),
},
)
const { thread: t } = data
assertHiddenPosts(t)
expect(t).toEqual([
expect.objectContaining({
uri: seed.r['1'].ref.uriStr,
value: expect.objectContaining(
propsHidden({ hiddenByThreadgate: true }),
),
}),
// No nested replies for hidden.
])
})
})
describe('tags', () => {
let seed: Awaited<ReturnType<typeof seedThreadV2.tags>>
beforeAll(async () => {
seed = await seedThreadV2.tags(sc)
await network.processAll()
})
describe('when prioritizing followed users', () => {
const prioritizeFollowedUsers = true
it('considers tags for bumping down and hiding', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
sort: 'newest',
prioritizeFollowedUsers,
},
{
headers: await network.serviceHeaders(
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(true)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({ uri: seed.root.ref.uriStr }),
// OP (down overridden).
expect.objectContaining({ uri: seed.r['3'].ref.uriStr }),
// Viewer (hide overridden).
expect.objectContaining({ uri: seed.r['4'].ref.uriStr }),
// Following (hide overridden).
expect.objectContaining({ uri: seed.r['5'].ref.uriStr }),
// Fot following.
expect.objectContaining({ uri: seed.r['0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['0.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['0.1'].ref.uriStr }),
// Down.
expect.objectContaining({ uri: seed.r['1'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['1.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['1.1'].ref.uriStr }),
])
})
it('finds the hidden by tag', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadOtherV2(
{
anchor: seed.root.ref.uriStr,
prioritizeFollowedUsers,
},
{
headers: await network.serviceHeaders(
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadOtherV2,
),
},
)
const { thread: t } = data
assertHiddenPosts(t)
expect(t).toEqual([
// Hide.
expect.objectContaining({ uri: seed.r['2'].ref.uriStr }),
])
})
})
describe('when not prioritizing followed users', () => {
const prioritizeFollowedUsers = false
it('considers tags for bumping down and hiding', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadV2(
{
anchor: seed.root.ref.uriStr,
sort: 'newest',
prioritizeFollowedUsers,
},
{
headers: await network.serviceHeaders(
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadV2,
),
},
)
const { thread: t, hasOtherReplies } = data
expect(hasOtherReplies).toBe(true)
assertPosts(t)
expect(t).toEqual([
expect.objectContaining({ uri: seed.root.ref.uriStr }),
// OP (down overridden).
expect.objectContaining({ uri: seed.r['3'].ref.uriStr }),
// Viewer (hide overriden).
expect.objectContaining({ uri: seed.r['4'].ref.uriStr }),
// Following was hidden because not prioritizing.
// Not following.
expect.objectContaining({ uri: seed.r['0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['0.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['0.1'].ref.uriStr }),
// Down.
expect.objectContaining({ uri: seed.r['1'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['1.0'].ref.uriStr }),
expect.objectContaining({ uri: seed.r['1.1'].ref.uriStr }),
])
})
it('finds the hidden by tag', async () => {
const { data } = await agent.app.bsky.unspecced.getPostThreadOtherV2(
{
anchor: seed.root.ref.uriStr,
prioritizeFollowedUsers,
},
{
headers: await network.serviceHeaders(
seed.users.viewer.did,
ids.AppBskyUnspeccedGetPostThreadOtherV2,
),
},
)
const { thread: t } = data
assertHiddenPosts(t)
expect(t).toEqual([
// Following (hide).
expect.objectContaining({ uri: seed.r['5'].ref.uriStr }),
// Hide.
expect.objectContaining({ uri: seed.r['2'].ref.uriStr }),
])
})
})
})
})
function assertPosts(
t: OutputSchemaThread['thread'],
): asserts t is ThreadItemValuePost[] {
t.forEach((i) => {
assert(
AppBskyUnspeccedDefs.isThreadItemPost(i.value),
`Expected thread item to have a post as value`,
)
})
}
function assertHiddenPosts(
t: OutputSchemaHiddenThread['thread'],
): asserts t is ThreadOtherItemValuePost[] {
t.forEach((i) => {
assert(
AppBskyUnspeccedDefs.isThreadItemPost(i.value),
`Expected thread item to have a hidden post as value`,
)
})
}