atproto/packages/bsky/tests/views/notifications.test.ts

1549 lines
44 KiB
TypeScript

import { AppBskyNotificationDeclaration, AtpAgent } from '@atproto/api'
import { SeedClient, TestNetwork, basicSeed } from '@atproto/dev-env'
import { TAG_HIDE } from '@atproto/dev-env/dist/seed/thread-v2'
import { delayCursor } from '../../src/api/app/bsky/notification/listNotifications'
import { ids } from '../../src/lexicon/lexicons'
import { ProfileView } from '../../src/lexicon/types/app/bsky/actor/defs'
import {
ActivitySubscription,
ChatPreference,
FilterablePreference,
Preference,
Preferences,
} from '../../src/lexicon/types/app/bsky/notification/defs'
import {
OutputSchema as ListActivitySubscriptionsOutputSchema,
QueryParams,
} from '../../src/lexicon/types/app/bsky/notification/listActivitySubscriptions'
import {
Notification,
OutputSchema as ListNotificationsOutputSchema,
} from '../../src/lexicon/types/app/bsky/notification/listNotifications'
import { InputSchema } from '../../src/lexicon/types/app/bsky/notification/putPreferencesV2'
import { Namespaces } from '../../src/stash'
import { forSnapshot, paginateAll } from '../_util'
type Database = TestNetwork['bsky']['db']
describe('notification views', () => {
let network: TestNetwork
let db: Database
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
let fred: string
let greg: string
let han: string
let blocked: string
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'bsky_views_notifications',
bsky: {
threadTagsHide: new Set([TAG_HIDE]),
},
})
db = network.bsky.db
agent = network.bsky.getClient()
pdsAgent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
await network.bsky.db.db
.updateTable('actor')
.set({ trustedVerifier: true })
.where('did', '=', alice)
.execute()
await sc.createAccount('eve', {
email: 'eve@test.com',
handle: 'eve.test',
password: 'eve-pass',
})
await sc.createAccount('fred', {
email: 'fred@test.com',
handle: 'fred.test',
password: 'fred-pass',
})
await sc.createAccount('greg', {
email: 'greg@test.com',
handle: 'greg.test',
password: 'greg-pass',
})
await sc.createAccount('han', {
email: 'han@test.com',
handle: 'han.test',
password: 'han-pass',
})
await sc.createAccount('blocked', {
email: 'blocked@test.com',
handle: 'blocked.test',
password: 'blocked-pass',
})
await network.processAll()
alice = sc.dids.alice
bob = sc.dids.bob
carol = sc.dids.carol
dan = sc.dids.dan
eve = sc.dids.eve
fred = sc.dids.fred
greg = sc.dids.greg
han = sc.dids.han
blocked = sc.dids.blocked
})
afterAll(async () => {
await network.close()
})
const sortNotifs = (notifs: Notification[]) => {
// Need to sort because notification ordering is not well-defined
return notifs.sort((a, b) => {
const stableUriA = a.uri.replace(
/\/did:plc:.+?\//,
`/${a.author.handle}/`,
)
const stableUriB = b.uri.replace(
/\/did:plc:.+?\//,
`/${b.author.handle}/`,
)
if (stableUriA === stableUriB) {
return a.indexedAt > b.indexedAt ? -1 : 1
}
return stableUriA > stableUriB ? -1 : 1
})
}
it('fetches notification count without a last-seen', async () => {
const notifCountAlice =
await agent.api.app.bsky.notification.getUnreadCount(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationGetUnreadCount,
),
},
)
expect(notifCountAlice.data.count).toBe(12)
const notifCountBob = await agent.api.app.bsky.notification.getUnreadCount(
{},
{
headers: await network.serviceHeaders(
sc.dids.bob,
ids.AppBskyNotificationGetUnreadCount,
),
},
)
expect(notifCountBob.data.count).toBeGreaterThanOrEqual(3)
})
it('generates notifications for all reply ancestors', async () => {
// Add to reply chain, post ancestors: alice -> bob -> alice -> carol.
// Should have added one notification for each of alice and bob.
await sc.reply(
sc.dids.carol,
sc.posts[alice][1].ref,
sc.replies[alice][0].ref,
'indeed',
)
await network.processAll()
const notifCountAlice =
await agent.api.app.bsky.notification.getUnreadCount(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationGetUnreadCount,
),
},
)
expect(notifCountAlice.data.count).toBe(13)
const notifCountBob = await agent.api.app.bsky.notification.getUnreadCount(
{},
{
headers: await network.serviceHeaders(
sc.dids.bob,
ids.AppBskyNotificationGetUnreadCount,
),
},
)
expect(notifCountBob.data.count).toBeGreaterThanOrEqual(4)
})
it('does not give notifs for a deleted subject', async () => {
const root = await sc.post(sc.dids.alice, 'root')
const first = await sc.reply(sc.dids.bob, root.ref, root.ref, 'first')
await sc.deletePost(sc.dids.alice, root.ref.uri)
const second = await sc.reply(sc.dids.carol, root.ref, first.ref, 'second')
await network.processAll()
const notifsAlice = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
sc.dids.alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const hasNotif = notifsAlice.data.notifications.some(
(notif) => notif.uri === second.ref.uriStr,
)
expect(hasNotif).toBe(false)
// cleanup
await sc.deletePost(sc.dids.bob, first.ref.uri)
await sc.deletePost(sc.dids.carol, second.ref.uri)
await network.processAll()
})
it('generates notifications for quotes', async () => {
// Dan was quoted by alice
const notifsDan = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
sc.dids.dan,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(
forSnapshot(sortNotifs(notifsDan.data.notifications)),
).toMatchSnapshot()
})
it('generates notifications for likes', async () => {
const notifsAlice = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const na = sortNotifs(
notifsAlice.data.notifications.filter((n) => n.reason === 'like'),
)
expect(na).toHaveLength(5)
expect(forSnapshot(na)).toMatchSnapshot()
})
it('generates notifications for reposts', async () => {
const notifsAlice = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const na = sortNotifs(
notifsAlice.data.notifications.filter((n) => n.reason === 'repost'),
)
expect(na).toHaveLength(2)
expect(forSnapshot(na)).toMatchSnapshot()
})
it('generates notifications for likes via repost', async () => {
const op = dan
const reposter = carol
const liker = alice
await sc.like(liker, sc.posts[op][1].ref, {
via: sc.reposts[reposter][0].raw,
})
await network.processAll()
const notifsOp = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
op,
ids.AppBskyNotificationListNotifications,
),
},
)
const no = sortNotifs(
notifsOp.data.notifications.filter((n) => n.reason === 'like'),
)
// Like from `alice` in this test.
expect(no).toHaveLength(1)
expect(forSnapshot(no)).toMatchSnapshot()
const notifsReposter = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
reposter,
ids.AppBskyNotificationListNotifications,
),
},
)
const nr = sortNotifs(
notifsReposter.data.notifications.filter(
(n) => n.reason === 'like-via-repost',
),
)
// Like from `alice` in this test.
expect(nr).toHaveLength(1)
expect(forSnapshot(nr)).toMatchSnapshot()
})
it('does not generate self notifications for likes via own repost', async () => {
const op = dan
const reposter = carol
await sc.like(reposter, sc.posts[op][1].ref, {
via: sc.reposts[reposter][0].raw,
})
await network.processAll()
const notifsOp = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
op,
ids.AppBskyNotificationListNotifications,
),
},
)
const no = sortNotifs(
notifsOp.data.notifications.filter((n) => n.reason === 'like'),
)
// Like from `alice` in previous test + `carol` on this test.
expect(no).toHaveLength(2)
expect(forSnapshot(no)).toMatchSnapshot()
const notifsReposter = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
reposter,
ids.AppBskyNotificationListNotifications,
),
},
)
const nr = sortNotifs(
notifsReposter.data.notifications.filter(
(n) => n.reason === 'like-via-repost',
),
)
// Like from `alice` in previous test.
expect(nr).toHaveLength(1)
expect(forSnapshot(nr)).toMatchSnapshot()
})
it('generates notifications for reposts via repost', async () => {
const op = dan
const reposter = carol
const reReposter = alice
await sc.repost(reReposter, sc.posts[op][1].ref, {
via: sc.reposts[reposter][0].raw,
})
await network.processAll()
const notifsOp = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
op,
ids.AppBskyNotificationListNotifications,
),
},
)
const no = sortNotifs(
notifsOp.data.notifications.filter((n) => n.reason === 'repost'),
)
// Repost from `carol` in seeds + `alice` on this test.
expect(no).toHaveLength(2)
expect(forSnapshot(no)).toMatchSnapshot()
const notifsReposter = await agent.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
reposter,
ids.AppBskyNotificationListNotifications,
),
},
)
const nr = sortNotifs(
notifsReposter.data.notifications.filter(
(n) => n.reason === 'repost-via-repost',
),
)
// Repost from `alice` in this test.
expect(nr).toHaveLength(1)
expect(forSnapshot(nr)).toMatchSnapshot()
})
it('generates notifications for verification created and removed', async () => {
await sc.verify(
sc.dids.alice,
sc.dids.bob,
sc.accounts[sc.dids.bob].handle,
sc.profiles[sc.dids.bob].displayName,
)
await network.processAll()
const notifsBob1 = await agent.app.bsky.notification.listNotifications(
{ reasons: ['verified', 'unverified'] },
{
headers: await network.serviceHeaders(
sc.dids.bob,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(
forSnapshot(sortNotifs(notifsBob1.data.notifications)),
).toMatchSnapshot()
await sc.unverify(sc.dids.alice, sc.dids.bob)
await network.processAll()
const notifsBob2 = await agent.app.bsky.notification.listNotifications(
{ reasons: ['verified', 'unverified'] },
{
headers: await network.serviceHeaders(
sc.dids.bob,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(
forSnapshot(sortNotifs(notifsBob2.data.notifications)),
).toMatchSnapshot()
})
it('fetches notifications without a last-seen', async () => {
const notifRes = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const notifs = notifRes.data.notifications
expect(notifs.length).toBe(13)
const readStates = notifs.map((notif) => notif.isRead)
expect(readStates).toEqual(notifs.map((_, i) => i !== 0)) // only first appears unread
expect(forSnapshot(sortNotifs(notifs))).toMatchSnapshot()
})
it('paginates', async () => {
const results = (results: ListNotificationsOutputSchema[]) =>
sortNotifs(results.flatMap((res) => res.notifications))
const paginator = async (cursor?: string) => {
const res = await agent.api.app.bsky.notification.listNotifications(
{ cursor, limit: 6 },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
return res.data
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.notifications.length).toBeLessThanOrEqual(6),
)
const full = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(full.data.notifications.length).toEqual(13)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
it('fetches notification count with a last-seen', async () => {
const full = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const seenAt = full.data.notifications[3].indexedAt
await agent.api.app.bsky.notification.updateSeen(
{ seenAt },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationUpdateSeen,
),
encoding: 'application/json',
},
)
const full2 = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(full2.data.notifications.length).toBe(full.data.notifications.length)
expect(full2.data.seenAt).toEqual(seenAt)
const notifCount = await agent.api.app.bsky.notification.getUnreadCount(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationGetUnreadCount,
),
},
)
expect(notifCount.data.count).toBe(
full.data.notifications.filter((n) => n.indexedAt > seenAt).length,
)
expect(notifCount.data.count).toBeGreaterThan(0)
// reset last-seen
await agent.api.app.bsky.notification.updateSeen(
{ seenAt: new Date(0).toISOString() },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationUpdateSeen,
),
encoding: 'application/json',
},
)
})
it('fetches notifications with a last-seen', async () => {
const full = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const seenAt = full.data.notifications[3].indexedAt
await agent.api.app.bsky.notification.updateSeen(
{ seenAt },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationUpdateSeen,
),
encoding: 'application/json',
},
)
const notifRes = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const notifs = notifRes.data.notifications
expect(notifs.length).toBe(13)
const readStates = notifs.map((notif) => notif.isRead)
expect(readStates).toEqual(notifs.map((n) => n.indexedAt < seenAt))
// reset last-seen
await agent.api.app.bsky.notification.updateSeen(
{ seenAt: new Date(0).toISOString() },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationUpdateSeen,
),
encoding: 'application/json',
},
)
})
it('fetches notifications omitting mentions and replies for taken-down posts', async () => {
const postRef1 = sc.replies[sc.dids.carol][0].ref // Reply
const postRef2 = sc.posts[sc.dids.dan][1].ref // Mention
await Promise.all(
[postRef1, postRef2].map((postRef) =>
network.bsky.ctx.dataplane.takedownRecord({
recordUri: postRef.uriStr,
}),
),
)
const notifRes = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
const notifCount = await agent.api.app.bsky.notification.getUnreadCount(
{},
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationGetUnreadCount,
),
},
)
const notifs = sortNotifs(notifRes.data.notifications)
expect(notifs.length).toBe(11)
expect(forSnapshot(notifs)).toMatchSnapshot()
expect(notifCount.data.count).toBe(11)
// Cleanup
await Promise.all(
[postRef1, postRef2].map((postRef) =>
network.bsky.ctx.dataplane.untakedownRecord({
recordUri: postRef.uriStr,
}),
),
)
})
it('fetches notifications with explicit priority', async () => {
const priority = await agent.api.app.bsky.notification.listNotifications(
{ priority: true },
{
headers: await network.serviceHeaders(
sc.dids.carol,
ids.AppBskyNotificationListNotifications,
),
},
)
// only notifs from follow (alice)
expect(
priority.data.notifications.every(
(notif) => ![sc.dids.bob, sc.dids.dan].includes(notif.author.did),
),
).toBe(true)
expect(forSnapshot(priority.data)).toMatchSnapshot()
const noPriority = await agent.api.app.bsky.notification.listNotifications(
{ priority: false },
{
headers: await network.serviceHeaders(
sc.dids.carol,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(forSnapshot(noPriority.data)).toMatchSnapshot()
})
it('fetches notifications with default priority', async () => {
await agent.api.app.bsky.notification.putPreferences(
{ priority: true },
{
encoding: 'application/json',
headers: await network.serviceHeaders(
sc.dids.carol,
ids.AppBskyNotificationPutPreferences,
),
},
)
await network.processAll()
const notifs = await agent.api.app.bsky.notification.listNotifications(
{},
{
headers: await network.serviceHeaders(
sc.dids.carol,
ids.AppBskyNotificationListNotifications,
),
},
)
// only notifs from follow (alice)
expect(
notifs.data.notifications.every(
(notif) => ![sc.dids.bob, sc.dids.dan].includes(notif.author.did),
),
).toBe(true)
expect(forSnapshot(notifs.data)).toMatchSnapshot()
await agent.api.app.bsky.notification.putPreferences(
{ priority: false },
{
encoding: 'application/json',
headers: await network.serviceHeaders(
sc.dids.carol,
ids.AppBskyNotificationPutPreferences,
),
},
)
await network.processAll()
})
it('filters notifications by reason', async () => {
const res = await agent.app.bsky.notification.listNotifications(
{
reasons: ['mention'],
},
{
headers: await network.serviceHeaders(
sc.dids.alice,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(res.data.notifications.length).toBe(1)
expect(forSnapshot(res.data)).toMatchSnapshot()
})
it('filters notifications by multiple reasons', async () => {
const res = await agent.app.bsky.notification.listNotifications(
{
reasons: ['mention', 'reply'],
},
{
headers: await network.serviceHeaders(
sc.dids.alice,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(res.data.notifications.length).toBe(4)
expect(forSnapshot(res.data)).toMatchSnapshot()
})
it('paginates filtered notifications', async () => {
const results = (results: ListNotificationsOutputSchema[]) =>
sortNotifs(results.flatMap((res) => res.notifications))
const paginator = async (cursor?: string) => {
const res = await agent.app.bsky.notification.listNotifications(
{ reasons: ['mention', 'reply'], cursor, limit: 2 },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
return res.data
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.notifications.length).toBeLessThanOrEqual(2),
)
const full = await agent.app.bsky.notification.listNotifications(
{ reasons: ['mention', 'reply'] },
{
headers: await network.serviceHeaders(
alice,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(full.data.notifications.length).toBe(4)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
describe('handles hide tag filters', () => {
beforeAll(async () => {
const danPost = await sc.post(sc.dids.dan, 'hello friends')
await network.processAll()
const eveReply = await sc.reply(
sc.dids.eve,
danPost.ref,
danPost.ref,
'no thanks',
)
await network.processAll()
await createTag(db, { uri: eveReply.ref.uri.toString(), val: TAG_HIDE })
})
it('filters posts with hide tag', async () => {
const results = await agent.app.bsky.notification.listNotifications(
{ reasons: ['reply'] },
{
headers: await network.serviceHeaders(
dan,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(results.data.notifications.length).toEqual(0)
expect(forSnapshot(results.data.notifications)).toMatchSnapshot()
})
it('shows posts with hide tag if they are followed', async () => {
await sc.follow(dan, eve)
await network.processAll()
const results = await agent.app.bsky.notification.listNotifications(
{ reasons: ['reply'] },
{
headers: await network.serviceHeaders(
dan,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(results.data.notifications.length).toEqual(1)
expect(forSnapshot(results.data.notifications)).toMatchSnapshot()
})
})
describe('notifications delay', () => {
const notificationsDelayMs = 5_000
let delayNetwork: TestNetwork
let delayAgent: AtpAgent
let delaySc: SeedClient
let delayAlice: string
beforeAll(async () => {
delayNetwork = await TestNetwork.create({
bsky: {
notificationsDelayMs,
},
dbPostgresSchema: 'bsky_views_notifications_delay',
})
delayAgent = delayNetwork.bsky.getClient()
delaySc = delayNetwork.getSeedClient()
await basicSeed(delaySc)
await delayNetwork.processAll()
delayAlice = delaySc.dids.alice
// Add to reply chain, post ancestors: alice -> bob -> alice -> carol.
// Should have added one notification for each of alice and bob.
await delaySc.reply(
delaySc.dids.carol,
delaySc.posts[delayAlice][1].ref,
delaySc.replies[delayAlice][0].ref,
'indeed',
)
await delayNetwork.processAll()
// @NOTE: Use fake timers after inserting seed data,
// to avoid inserting all notifications with the same timestamp.
jest.useFakeTimers({
doNotFake: [
'nextTick',
'performance',
'setImmediate',
'setInterval',
'setTimeout',
],
})
})
afterAll(async () => {
jest.useRealTimers()
await delayNetwork.close()
})
it('paginates', async () => {
const firstNotification = await delayNetwork.bsky.db.db
.selectFrom('notification')
.selectAll()
.limit(1)
.orderBy('sortAt', 'asc')
.executeTakeFirstOrThrow()
// Sets the system time to when the first notification happened.
// At this point we won't have any notifications that already crossed the delay threshold.
jest.setSystemTime(new Date(firstNotification.sortAt))
const results = (results: ListNotificationsOutputSchema[]) =>
sortNotifs(results.flatMap((res) => res.notifications))
const paginator = async (cursor?: string) => {
const res =
await delayAgent.api.app.bsky.notification.listNotifications(
{ cursor, limit: 6 },
{
headers: await delayNetwork.serviceHeaders(
delayAlice,
ids.AppBskyNotificationListNotifications,
),
},
)
return res.data
}
const paginatedAllBeforeDelay = await paginateAll(paginator)
paginatedAllBeforeDelay.forEach((res) =>
expect(res.notifications.length).toBe(0),
)
const fullBeforeDelay =
await delayAgent.api.app.bsky.notification.listNotifications(
{},
{
headers: await delayNetwork.serviceHeaders(
delayAlice,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(fullBeforeDelay.data.notifications.length).toEqual(0)
expect(results(paginatedAllBeforeDelay)).toEqual(
results([fullBeforeDelay.data]),
)
const lastNotification = await delayNetwork.bsky.db.db
.selectFrom('notification')
.selectAll()
.limit(1)
.orderBy('sortAt', 'desc')
.executeTakeFirstOrThrow()
// Sets the system time to when the last notification happened and the delay has elapsed.
// At this point we all notifications already crossed the delay threshold.
jest.setSystemTime(
new Date(
new Date(lastNotification.sortAt).getTime() +
notificationsDelayMs +
1,
),
)
const paginatedAllAfterDelay = await paginateAll(paginator)
paginatedAllAfterDelay.forEach((res) =>
expect(res.notifications.length).toBeLessThanOrEqual(6),
)
const fullAfterDelay =
await delayAgent.api.app.bsky.notification.listNotifications(
{},
{
headers: await delayNetwork.serviceHeaders(
delayAlice,
ids.AppBskyNotificationListNotifications,
),
},
)
expect(fullAfterDelay.data.notifications.length).toEqual(13)
expect(results(paginatedAllAfterDelay)).toEqual(
results([fullAfterDelay.data]),
)
})
describe('cursor delay', () => {
const delay0s = 0
const delay5s = 5_000
const now = '2021-01-01T01:00:00.000Z'
const nowMinus2s = '2021-01-01T00:59:58.000Z'
const nowMinus5s = '2021-01-01T00:59:55.000Z'
const nowMinus8s = '2021-01-01T00:59:52.000Z'
beforeAll(async () => {
jest.useFakeTimers({ doNotFake: ['performance'] })
jest.setSystemTime(new Date(now))
})
afterAll(async () => {
jest.useRealTimers()
})
describe('for undefined cursor', () => {
it('returns now minus delay', async () => {
const delayedCursor = delayCursor(undefined, delay5s)
expect(delayedCursor).toBe(nowMinus5s)
})
it('returns now if delay is 0', async () => {
const delayedCursor = delayCursor(undefined, delay0s)
expect(delayedCursor).toBe(now)
})
})
describe('for defined cursor', () => {
it('returns original cursor if delay is 0', async () => {
const originalCursor = nowMinus2s
const delayedCursor = delayCursor(originalCursor, delay0s)
expect(delayedCursor).toBe(originalCursor)
})
it('returns "now minus delay" for cursor that is after that', async () => {
// Cursor is "now - 2s", should become "now - 5s"
const originalCursor = nowMinus2s
const cursor = delayCursor(originalCursor, delay5s)
expect(cursor).toBe(nowMinus5s)
})
it('returns original cursor for cursor that is before "now minus delay"', async () => {
// Cursor is "now - 8s", should stay like that.
const originalCursor = nowMinus8s
const cursor = delayCursor(originalCursor, delay5s)
expect(cursor).toBe(originalCursor)
})
it('passes through a non-date cursor', async () => {
const originalCursor = '123_abc'
const cursor = delayCursor(originalCursor, delay5s)
expect(cursor).toBe(originalCursor)
})
})
})
})
describe('preferences v2', () => {
beforeEach(async () => {
await clearPrivateData(db)
})
// Defaults
const fp: FilterablePreference = {
include: 'all',
list: true,
push: true,
}
const p: Preference = {
list: true,
push: true,
}
const cp: ChatPreference = {
include: 'all',
push: true,
}
it('gets preferences filling up with the defaults', async () => {
const actorDid = sc.dids.carol
const getAndAssert = async (
expectedApi: Preferences,
expectedDb: Preferences | undefined,
) => {
const { data } = await agent.app.bsky.notification.getPreferences(
{},
{
headers: await network.serviceHeaders(
actorDid,
ids.AppBskyNotificationGetPreferences,
),
},
)
expect(data.preferences).toStrictEqual(expectedApi)
const dbResult = await db.db
.selectFrom('private_data')
.selectAll()
.where('actorDid', '=', actorDid)
.where(
'namespace',
'=',
Namespaces.AppBskyNotificationDefsPreferences,
)
.where('key', '=', 'self')
.executeTakeFirst()
if (dbResult === undefined) {
expect(dbResult).toBe(expectedDb)
} else {
expect(dbResult).toStrictEqual({
actorDid: actorDid,
namespace: Namespaces.AppBskyNotificationDefsPreferences,
key: 'self',
indexedAt: expect.any(String),
payload: expect.anything(), // Better to compare payload parsed.
updatedAt: expect.any(String),
})
expect(JSON.parse(dbResult.payload)).toStrictEqual({
$type: Namespaces.AppBskyNotificationDefsPreferences,
...expectedDb,
})
}
}
const expectedApi0: Preferences = {
chat: cp,
follow: fp,
like: fp,
likeViaRepost: fp,
mention: fp,
quote: fp,
reply: fp,
repost: fp,
repostViaRepost: fp,
starterpackJoined: p,
subscribedPost: p,
unverified: p,
verified: p,
}
// The user has no preferences set yet, so nothing stored.
const expectedDb0 = undefined
await getAndAssert(expectedApi0, expectedDb0)
await agent.app.bsky.notification.putPreferencesV2(
{ verified: { list: false, push: false } },
{
encoding: 'application/json',
headers: await network.serviceHeaders(
actorDid,
ids.AppBskyNotificationPutPreferencesV2,
),
},
)
await network.processAll()
const expectedApi1: Preferences = {
chat: cp,
follow: fp,
like: fp,
likeViaRepost: fp,
mention: fp,
quote: fp,
reply: fp,
repost: fp,
repostViaRepost: fp,
starterpackJoined: p,
subscribedPost: p,
unverified: p,
verified: { list: false, push: false },
}
// Stored all the defaults.
const expectedDb1 = expectedApi1
await getAndAssert(expectedApi1, expectedDb1)
})
it('stores the preferences setting the defaults', async () => {
const actorDid = sc.dids.carol
const putAndAssert = async (
input: InputSchema,
expected: Preferences,
) => {
const { data } = await agent.app.bsky.notification.putPreferencesV2(
input,
{
encoding: 'application/json',
headers: await network.serviceHeaders(
actorDid,
ids.AppBskyNotificationPutPreferencesV2,
),
},
)
await network.processAll()
expect(data.preferences).toStrictEqual(expected)
const dbResult = await db.db
.selectFrom('private_data')
.selectAll()
.where('actorDid', '=', actorDid)
.where(
'namespace',
'=',
Namespaces.AppBskyNotificationDefsPreferences,
)
.where('key', '=', 'self')
.executeTakeFirstOrThrow()
expect(dbResult).toStrictEqual({
actorDid: actorDid,
namespace: Namespaces.AppBskyNotificationDefsPreferences,
key: 'self',
indexedAt: expect.any(String),
payload: expect.anything(), // Better to compare payload parsed.
updatedAt: expect.any(String),
})
expect(JSON.parse(dbResult.payload)).toStrictEqual({
$type: Namespaces.AppBskyNotificationDefsPreferences,
...expected,
})
}
const input0 = {
chat: {
push: false,
include: 'accepted',
},
}
const expected0: Preferences = {
chat: input0.chat,
follow: fp,
like: fp,
likeViaRepost: fp,
mention: fp,
quote: fp,
reply: fp,
repost: fp,
repostViaRepost: fp,
starterpackJoined: p,
subscribedPost: p,
unverified: p,
verified: p,
}
await putAndAssert(input0, expected0)
const input1 = {
mention: {
list: false,
push: false,
include: 'follows',
},
}
const expected1: Preferences = {
// Kept from the previous call.
chat: input0.chat,
follow: fp,
like: fp,
likeViaRepost: fp,
mention: input1.mention,
quote: fp,
reply: fp,
repost: fp,
repostViaRepost: fp,
starterpackJoined: p,
subscribedPost: p,
unverified: p,
verified: p,
}
await putAndAssert(input1, expected1)
})
})
describe('activity subscriptions', () => {
const sortProfiles = (profiles: ProfileView[]) => {
return profiles.sort((a, b) => (a.handle > b.handle ? 1 : -1))
}
const declare = async (actor: string, value: string) => {
await pdsAgent.com.atproto.repo.createRecord(
{
repo: actor,
collection: ids.AppBskyNotificationDeclaration,
rkey: 'self',
record: {
allowSubscriptions: value,
} as AppBskyNotificationDeclaration.Record,
},
{ headers: sc.getHeaders(actor), encoding: 'application/json' },
)
}
const put = async (
actor: string,
subject: string,
val: ActivitySubscription,
) =>
agent.app.bsky.notification.putActivitySubscription(
{
subject,
activitySubscription: val,
},
{
headers: await network.serviceHeaders(
actor,
ids.AppBskyNotificationPutActivitySubscription,
),
},
)
const list = async (actor: string, params?: QueryParams) =>
agent.app.bsky.notification.listActivitySubscriptions(params ?? {}, {
headers: await network.serviceHeaders(
actor,
ids.AppBskyNotificationListActivitySubscriptions,
),
})
const associatedAllowSub = async (actor: string, subject: string) => {
const { data } = await agent.app.bsky.actor.getProfile(
{ actor: subject },
{
headers: await network.serviceHeaders(
actor,
ids.AppBskyActorGetProfile,
),
},
)
return data.associated?.activitySubscription?.allowSubscriptions
}
const viewerActivitySub = async (actor: string, subject: string) => {
const { data } = await agent.app.bsky.actor.getProfile(
{ actor: subject },
{
headers: await network.serviceHeaders(
actor,
ids.AppBskyActorGetProfile,
),
},
)
return data.viewer?.activitySubscription
}
beforeAll(async () => {
// 'none' declaration.
await declare(bob, 'none')
// 'mutuals' declaration and both follow.
await declare(carol, 'mutuals')
await sc.follow(alice, carol)
await sc.follow(carol, alice)
// 'mutuals' declaration but only actor follows.
await declare(dan, 'mutuals')
await sc.follow(alice, dan)
// 'mutuals' declaration but only subject follows.
await declare(eve, 'mutuals')
await sc.follow(eve, alice)
// 'followers' declaration and viewer follows.
await declare(fred, 'followers')
await sc.follow(alice, fred)
// 'followers' declaration but viewer does not follow.
await declare(greg, 'followers')
// blocked.
await declare(blocked, 'followers')
await sc.block(alice, blocked)
await network.processAll()
})
beforeEach(async () => {
await clearActivitySubscription(db)
})
it('lists an empty list of subscriptions', async () => {
const actorDid = alice
const { data } = await list(actorDid)
expect(data.cursor).toBeUndefined()
expect(data.subscriptions).toHaveLength(0)
})
it('does not allow subscribing to self', async () => {
const actorDid = alice
const promise = put(actorDid, actorDid, { post: true, reply: false })
await expect(promise).rejects.toThrow('Cannot subscribe to own activity')
})
it('inserts a subscription entry if it does not exist', async () => {
const actorDid = alice
const subjectDid = fred
const val = { post: true, reply: false }
const { data: createData } = await put(actorDid, subjectDid, val)
expect(createData).toStrictEqual({
subject: subjectDid,
activitySubscription: val,
})
const { data: listData } = await list(actorDid)
expect(listData).toEqual({
cursor: expect.any(String),
subscriptions: [
expect.objectContaining({
did: subjectDid,
viewer: expect.objectContaining({ activitySubscription: val }),
}),
],
})
})
it('updates a subscription entry if it exists', async () => {
const actorDid = alice
const subjectDid = fred
const valCreate = { post: true, reply: false }
const valUpdate = { post: false, reply: true }
const { data: createData } = await put(actorDid, subjectDid, valCreate)
expect(createData).toStrictEqual({
subject: subjectDid,
activitySubscription: valCreate,
})
const { data: updateData } = await put(actorDid, subjectDid, valUpdate)
expect(updateData).toStrictEqual({
subject: subjectDid,
activitySubscription: valUpdate,
})
const { data: listData } = await list(actorDid)
expect(listData).toEqual({
cursor: expect.any(String),
subscriptions: [
expect.objectContaining({
did: subjectDid,
viewer: expect.objectContaining({
activitySubscription: valUpdate,
}),
}),
],
})
})
it('deletes a subscription entry when all options are turned off', async () => {
const actorDid = alice
const subjectDid = fred
const valCreate = { post: true, reply: false }
const valDelete = { post: false, reply: false }
await put(actorDid, subjectDid, valCreate)
const { data: list0 } = await list(actorDid)
expect(list0.subscriptions).toHaveLength(1)
await put(actorDid, subjectDid, valDelete)
const { data: list1 } = await list(actorDid)
expect(list1.subscriptions).toHaveLength(0)
})
it('paginates', async () => {
const actorDid = alice
const limit = 2
const val = { post: true, reply: false }
await put(actorDid, bob, val)
await put(actorDid, carol, val)
await put(actorDid, dan, val)
await put(actorDid, eve, val)
await put(actorDid, fred, val)
await put(actorDid, blocked, val) // blocked is removed from the list.
const results = (results: ListActivitySubscriptionsOutputSchema[]) =>
sortProfiles(
results.flatMap(
(res: ListActivitySubscriptionsOutputSchema) => res.subscriptions,
),
)
const paginator = async (cursor?: string) => {
const { data } = await list(actorDid, { cursor, limit })
return data
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.subscriptions.length).toBeLessThanOrEqual(limit),
)
const full = await list(actorDid)
expect(full.data.subscriptions.length).toEqual(5)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
it('gets the declaration record', async () => {
const declaration = await pdsAgent.com.atproto.repo.getRecord({
repo: carol,
collection: 'app.bsky.notification.declaration',
rkey: 'self',
})
expect(declaration.data.value.allowSubscriptions).toEqual('mutuals')
})
describe('activity subscription declaration', () => {
it('includes the declaration in the profile view', async () => {
await expect(associatedAllowSub(alice, bob)).resolves.toBe('none')
await expect(associatedAllowSub(alice, carol)).resolves.toBe('mutuals')
await expect(associatedAllowSub(alice, dan)).resolves.toBe('mutuals')
await expect(associatedAllowSub(alice, eve)).resolves.toBe('mutuals')
await expect(associatedAllowSub(alice, fred)).resolves.toBe('followers')
await expect(associatedAllowSub(alice, greg)).resolves.toBe('followers')
})
})
describe('activity subscription viewer state', () => {
it('includes the relationship in the profile view', async () => {
const viewer = alice
const val = { post: true, reply: true }
// 'none' declaration.
await put(viewer, bob, val)
await expect(viewerActivitySub(viewer, bob)).resolves.toBeUndefined()
// 'mutuals' declaration and both follow.
await put(viewer, carol, val)
await expect(viewerActivitySub(viewer, carol)).resolves.toStrictEqual(
val,
)
// 'mutuals' declaration but only actor follows.
await put(viewer, dan, val)
await expect(viewerActivitySub(viewer, dan)).resolves.toBeUndefined()
// 'mutuals' declaration but only subject follows.
await put(viewer, eve, val)
await expect(viewerActivitySub(viewer, eve)).resolves.toBeUndefined()
// 'followers' declaration and viewer follows.
await put(viewer, fred, val)
await expect(viewerActivitySub(viewer, carol)).resolves.toStrictEqual(
val,
)
// 'followers' declaration but viewer does not follow.
await expect(viewerActivitySub(viewer, greg)).resolves.toBeUndefined()
// no declaration
await expect(viewerActivitySub(viewer, han)).resolves.toBeUndefined()
})
})
})
})
const clearPrivateData = async (db: Database) => {
await db.db.deleteFrom('private_data').execute()
}
const clearActivitySubscription = async (db: Database) => {
await db.db.deleteFrom('activity_subscription').execute()
}
const createTag = async (
db: Database,
opts: {
uri: string
val: string
},
) => {
await db.db
.updateTable('record')
.set({
tags: JSON.stringify([opts.val]),
})
.where('uri', '=', opts.uri)
.returningAll()
.execute()
}