From 6e374200e59d5a37ec1d796fc3fb2b213fb53bfc Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Mon, 24 Mar 2025 12:00:08 -0400 Subject: [PATCH] send ap follow/undo-follow if remote category watch state changes --- src/user/categories.js | 14 ++- test/activitypub/actors.js | 235 ++++++++++++++++++++++++++----------- 2 files changed, 181 insertions(+), 68 deletions(-) diff --git a/src/user/categories.js b/src/user/categories.js index f64ff53d3b..fcd155aeb2 100644 --- a/src/user/categories.js +++ b/src/user/categories.js @@ -5,6 +5,7 @@ const _ = require('lodash'); const db = require('../database'); const categories = require('../categories'); const plugins = require('../plugins'); +const api = require('../api'); const utils = require('../utils'); module.exports = function (User) { @@ -27,7 +28,18 @@ module.exports = function (User) { if (exists.includes(false)) { throw new Error('[[error:no-category]]'); } - await db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid); + + const apiMethod = state >= categories.watchStates.tracking ? 'follow' : 'unfollow'; + const follows = cids.filter(cid => !utils.isNumber(cid)).map(cid => api.activitypub[apiMethod]({ uid }, { + type: 'uid', + id: uid, + actor: cid, + })); // returns promises + + await Promise.all([ + db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid), + ...follows, + ]); }; User.getCategoryWatchState = async function (uid) { diff --git a/test/activitypub/actors.js b/test/activitypub/actors.js index dd727179d4..a80890cb94 100644 --- a/test/activitypub/actors.js +++ b/test/activitypub/actors.js @@ -148,79 +148,180 @@ describe('Actor asserton', () => { }); }); -describe('Group assertion', () => { - let actorUri; - let actorData; +describe('as:Group', () => { + describe('assertion', () => { + let actorUri; + let actorData; - before(async () => { - const { id, actor } = helpers.mocks.group(); - actorUri = id; - actorData = actor; - }); - - it('should assert a uri identifying as "Group" into a remote category', async () => { - const assertion = await activitypub.actors.assertGroup([actorUri]); - - assert(assertion, Array.isArray(assertion)); - assert.strictEqual(assertion.length, 1); - - const category = assertion.pop(); - assert.strictEqual(category.cid, actorUri); - }); - - it('should be considered existing when checked', async () => { - const exists = await categories.exists(actorUri); - - assert(exists); - }); - - it('should contain an entry in categories search zset', async () => { - const exists = await db.isSortedSetMember('categories:name', `${actorData.name.toLowerCase()}:${actorUri}`); - - assert(exists); - }); - - it('should return category data when getter methods are called', async () => { - const category = await categories.getCategoryData(actorUri); - assert(category); - assert.strictEqual(category.cid, actorUri); - }); - - it('should not assert non-group users when called', async () => { - const { id } = helpers.mocks.person(); - const assertion = await activitypub.actors.assertGroup([id]); - - assert(Array.isArray(assertion) && !assertion.length); - }); - - describe.only('deletion', () => { - it('should delete a remote category when Categories.purge is called', async () => { - const { id } = helpers.mocks.group(); - await activitypub.actors.assertGroup([id]); - - let exists = await categories.exists(id); - assert(exists); - - await categories.purge(id, 0); - - exists = await categories.exists(id); - assert(!exists); - - exists = await db.exists(`categoryRemote:${id}`); - assert(!exists); + before(async () => { + const { id, actor } = helpers.mocks.group(); + actorUri = id; + actorData = actor; }); - it('should also delete AP-specific keys that were added by assertGroup', async () => { - const { id } = helpers.mocks.group(); + it('should assert a uri identifying as "Group" into a remote category', async () => { + const assertion = await activitypub.actors.assertGroup([actorUri]); + + assert(assertion, Array.isArray(assertion)); + assert.strictEqual(assertion.length, 1); + + const category = assertion.pop(); + assert.strictEqual(category.cid, actorUri); + }); + + it('should be considered existing when checked', async () => { + const exists = await categories.exists(actorUri); + + assert(exists); + }); + + it('should contain an entry in categories search zset', async () => { + const exists = await db.isSortedSetMember('categories:name', `${actorData.name.toLowerCase()}:${actorUri}`); + + assert(exists); + }); + + it('should return category data when getter methods are called', async () => { + const category = await categories.getCategoryData(actorUri); + assert(category); + assert.strictEqual(category.cid, actorUri); + }); + + it('should not assert non-group users when called', async () => { + const { id } = helpers.mocks.person(); const assertion = await activitypub.actors.assertGroup([id]); - const [{ handle, slug }] = assertion; - await categories.purge(id, 0); + assert(Array.isArray(assertion) && !assertion.length); + }); - const isMember = await db.isObjectField('handle:cid', handle); - const inSearch = await db.isSortedSetMember('categories:name', `${slug}:${id}`); - assert(!isMember); - assert(!inSearch); + describe('deletion', () => { + it('should delete a remote category when Categories.purge is called', async () => { + const { id } = helpers.mocks.group(); + await activitypub.actors.assertGroup([id]); + + let exists = await categories.exists(id); + assert(exists); + + await categories.purge(id, 0); + + exists = await categories.exists(id); + assert(!exists); + + exists = await db.exists(`categoryRemote:${id}`); + assert(!exists); + }); + + it('should also delete AP-specific keys that were added by assertGroup', async () => { + const { id } = helpers.mocks.group(); + const assertion = await activitypub.actors.assertGroup([id]); + const [{ handle, slug }] = assertion; + + await categories.purge(id, 0); + + const isMember = await db.isObjectField('handle:cid', handle); + const inSearch = await db.isSortedSetMember('categories:name', `${slug}:${id}`); + assert(!isMember); + assert(!inSearch); + }); + }); + }); + + describe('following', () => { + let uid; + let cid; + + beforeEach(async () => { + uid = await user.create({ username: utils.generateUUID() }); + ({ id: cid } = helpers.mocks.group()); + await activitypub.actors.assertGroup([cid]); + }); + + afterEach(async () => { + activitypub._sent.clear(); + }); + + describe('user not already following', () => { + it('should report a watch state consistent with not following', async () => { + const states = await categories.getWatchState([cid], uid); + assert(states[0] <= categories.watchStates.notwatching); + }); + + it('should do nothing when category is a local category', async () => { + const { cid } = await categories.create({ name: utils.generateUUID() }); + await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking); + assert.strictEqual(activitypub._sent.size, 0); + }); + + it('should do nothing when watch state changes to "ignoring"', async () => { + await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); + assert.strictEqual(activitypub._sent.size, 0); + }); + + it('should send out a Follow activity when watch state changes to "tracking"', async () => { + await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking); + + assert.strictEqual(activitypub._sent.size, 1); + + const activity = Array.from(activitypub._sent.values()).pop(); + assert.strictEqual(activity.type, 'Follow'); + assert.strictEqual(activity.object, cid); + }); + + it('should send out a Follow activity when the watch state changes to "watching"', async () => { + await user.setCategoryWatchState(uid, cid, categories.watchStates.watching); + + assert.strictEqual(activitypub._sent.size, 1); + + const activity = Array.from(activitypub._sent.values()).pop(); + assert(activity && activity.object && typeof activity.object === 'string'); + assert.strictEqual(activity.type, 'Follow'); + assert.strictEqual(activity.object, cid); + }); + }); + + describe('user already following', () => { + beforeEach(async () => { + await Promise.all([ + user.setCategoryWatchState(uid, cid, categories.watchStates.tracking), + db.sortedSetAdd(`followingRemote:${uid}`, Date.now(), cid), + ]); + + activitypub._sent.clear(); + }); + + it('should report a watch state consistent with following', async () => { + const states = await categories.getWatchState([cid], uid); + assert(states[0] >= categories.watchStates.tracking); + }); + + it('should do nothing when category is a local category', async () => { + const { cid } = await categories.create({ name: utils.generateUUID() }); + await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); + assert.strictEqual(activitypub._sent.size, 0); + }); + + it('should do nothing when watch state changes to "tracking"', async () => { + await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking); + assert.strictEqual(activitypub._sent.size, 0); + }); + + it('should do nothing when watch state changes to "watching"', async () => { + await user.setCategoryWatchState(uid, cid, categories.watchStates.watching); + assert.strictEqual(activitypub._sent.size, 0); + }); + + it('should send out an Undo(Follow) activity when watch state changes to "ignoring"', async () => { + await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); + + assert.strictEqual(activitypub._sent.size, 1); + + const activity = Array.from(activitypub._sent.values()).pop(); + assert(activity && activity.object && typeof activity.object === 'object'); + assert.strictEqual(activity.type, 'Undo'); + assert.strictEqual(activity.object.type, 'Follow'); + assert.strictEqual(activity.object.actor, `${nconf.get('url')}/uid/${uid}`); + assert.strictEqual(activity.object.object, cid); + }); }); }); });