Compare commits

...

7 Commits

Author SHA1 Message Date
Julian Lam
ce8c0d10e8 refactor: crossposts.get to return limited category data (name, icon, etc.), fixed up crosspost modal to hide uncategorized and all categories options 2025-12-15 10:38:51 -05:00
Julian Lam
677e01ab39 refactor: move crosspost methods into their own file in src/topics 2025-12-12 14:00:04 -05:00
Julian Lam
2661c63e1b feat: introduce new front-end UI button for cross-posting, hide move on topics in remote cids
- Hide the ability to select remote cids in topic move category search
- Add a new option to category search: 'localOnly'; pretty self descriptive.
2025-12-11 16:03:19 -05:00
Julian Lam
dddc43e0fe feat: disallow moving topics to and from remote categories, + basic tests for topic moving 2025-12-11 15:32:30 -05:00
Julian Lam
a98b1ca39d test: new test file for crossposts 2025-12-11 15:32:30 -05:00
Julian Lam
a6178a837f feat: API v3 calls to crosspost and uncrosspost a topic to and from a category 2025-12-11 15:32:30 -05:00
Julian Lam
5ad54531c6 test: additional logic to allow multi-typing in schema type 2025-12-11 15:32:30 -05:00
25 changed files with 579 additions and 14 deletions

View File

@@ -170,6 +170,8 @@
"topic-already-deleted": "This topic has already been deleted",
"topic-already-restored": "This topic has already been restored",
"topic-already-crossposted": "This topic has already been cross-posted there.",
"cant-purge-main-post": "You can't purge the main post, please delete the topic instead",
"topic-thumbnails-are-disabled": "Topic thumbnails are disabled.",
@@ -262,6 +264,7 @@
"no-topics-selected": "No topics selected!",
"cant-move-to-same-topic": "Can't move post to same topic!",
"cant-move-topic-to-same-category": "Can't move topic to the same category!",
"cant-move-topic-to-from-remote-categories": "You cannot move topics in or out of remote categories; consider cross-posting instead.",
"cannot-block-self": "You cannot block yourself!",
"cannot-block-privileged": "You cannot block administrators or global moderators",

View File

@@ -116,6 +116,7 @@
"thread-tools.lock": "Lock Topic",
"thread-tools.unlock": "Unlock Topic",
"thread-tools.move": "Move Topic",
"thread-tools.crosspost": "Crosspost Topic",
"thread-tools.move-posts": "Move Posts",
"thread-tools.move-all": "Move All",
"thread-tools.change-owner": "Change Owner",
@@ -149,6 +150,7 @@
"load-categories": "Loading Categories",
"confirm-move": "Move",
"confirm-crosspost": "Cross-post",
"confirm-fork": "Fork",
"bookmark": "Bookmark",
@@ -161,6 +163,7 @@
"loading-more-posts": "Loading More Posts",
"move-topic": "Move Topic",
"move-topics": "Move Topics",
"crosspost-topic": "Cross-post Topic",
"move-post": "Move Post",
"post-moved": "Post moved!",
"fork-topic": "Fork Topic",
@@ -181,6 +184,7 @@
"topic-id": "Topic ID",
"move-posts-instruction": "Click the posts you want to move then enter a topic ID or go to the target topic",
"move-topic-instruction": "Select the target category and then click move",
"crosspost-topic-instruction": "Select one or more categories to cross-post to. Topic(s) will be accessible from the original category and all cross-posted categories.",
"change-owner-instruction": "Click the posts you want to assign to another user",
"manage-editors-instruction": "Manage the users who can edit this post below.",

View File

@@ -0,0 +1,34 @@
CrosspostObject:
type: object
properties:
id:
type: string
description: The cross-post ID
cid:
type: object
description: The category id that the topic was cross-posted to
additionalProperties:
oneOf:
- type: string
- type: number
tid:
type: object
description: The topic id that was cross-posted
additionalProperties:
oneOf:
- type: string
- type: number
timestamp:
type: number
uid:
type: object
description: The user id that initiated the cross-post
additionalProperties:
oneOf:
- type: string
- type: number
CrosspostsArray:
type: array
description: A list of crosspost objects
items:
$ref: '#/CrosspostObject'

View File

@@ -168,6 +168,8 @@ paths:
$ref: 'write/topics/tid/bump.yaml'
/topics/{tid}/move:
$ref: 'write/topics/tid/move.yaml'
/topics/{tid}/crossposts:
$ref: 'write/topics/tid/crossposts.yaml'
/tags/{tag}/follow:
$ref: 'write/tags/tag/follow.yaml'
/posts/{pid}:

View File

@@ -86,7 +86,6 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object
@@ -103,7 +102,6 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object

View File

@@ -47,7 +47,6 @@ get:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
isPrivate:
type: boolean
@@ -65,7 +64,6 @@ get:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object

View File

@@ -93,7 +93,6 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
groups:
type: array
@@ -107,7 +106,6 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object
@@ -230,7 +228,6 @@ delete:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
groups:
type: array
@@ -244,7 +241,6 @@ delete:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object

View File

@@ -71,5 +71,4 @@ get:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false

View File

@@ -0,0 +1,76 @@
post:
tags:
- topics
summary: crosspost a topic
description: This operation crossposts a topic to another category.
parameters:
- in: path
name: tid
schema:
type: string
required: true
description: a valid topic id
example: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
cid:
type: number
example: 1
responses:
'200':
description: Topic successfully crossposted
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: object
properties:
crossposts:
$ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray
delete:
tags:
- topics
summary: uncrossposts a topic
description: This operation uncrossposts a topic from a category.
parameters:
- in: path
name: tid
schema:
type: string
required: true
description: a valid topic id
example: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
cid:
type: number
example: 1
responses:
'200':
description: Topic successfully uncrossposted
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: object
properties:
crossposts:
$ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray

View File

@@ -34,6 +34,7 @@ define('forum/topic/move', [
categorySelector.init(dropdownEl, {
onSelect: onCategorySelected,
privilege: 'moderate',
localOnly: true,
});
modal.find('#move_thread_commit').on('click', onCommitClicked);

View File

@@ -116,6 +116,12 @@ define('forum/topic/threadTools', [
return false;
});
topicContainer.on('click', '[component="topic/crosspost"]', () => {
require(['forum/topic/crosspost'], (crosspost) => {
crosspost.init(tid, ajaxify.data.cid);
});
});
topicContainer.on('click', '[component="topic/delete/posts"]', function () {
require(['forum/topic/delete-posts'], function (deletePosts) {
deletePosts.init();

View File

@@ -76,6 +76,8 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
privilege: options.privilege,
states: options.states,
showLinks: options.showLinks,
localOnly: options.localOnly,
hideUncategorized: options.hideUncategorized,
}, function (err, { categories }) {
if (err) {
return alerts.error(err);
@@ -93,6 +95,7 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
categoryItems: categories.slice(0, 200),
selectedCategory: ajaxify.data.selectedCategory,
allCategoriesUrl: ajaxify.data.allCategoriesUrl,
hideAll: options.hideAll,
}, function (html) {
el.find('[component="category/list"]')
.html(html.find('[component="category/list"]').html());

View File

@@ -9,6 +9,7 @@ const messaging = require('../messaging');
const privileges = require('../privileges');
const meta = require('../meta');
const plugins = require('../plugins');
const utils = require('../utils');
const controllersHelpers = require('../controllers/helpers');
@@ -29,9 +30,12 @@ searchApi.categories = async (caller, data) => {
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
} else {
cids = await loadCids(caller.uid, data.parentCid);
if (meta.config.activitypubEnabled) {
if (!data.hideUncategorized && meta.config.activitypubEnabled) {
cids.unshift(-1);
}
if (data.localOnly) {
cids = cids.filter(cid => utils.isNumber(cid));
}
}
const visibleCategories = await controllersHelpers.getVisibleCategories({
@@ -66,6 +70,7 @@ async function findMatchedCids(uid, data) {
query: data.search,
qs: data.query,
paginate: false,
localOnly: data.localOnly,
});
let matchedCids = result.categories.map(c => c.cid);

View File

@@ -6,12 +6,14 @@ const privileges = require('../privileges');
const activitypub = require('../activitypub');
const plugins = require('../plugins');
const db = require('../database');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.search = async function (data) {
const query = data.query || '';
const page = data.page || 1;
const uid = data.uid || 0;
const localOnly = data.localOnly || false;
const paginate = data.hasOwnProperty('paginate') ? data.paginate : true;
const startTime = process.hrtime();
@@ -21,6 +23,9 @@ module.exports = function (Categories) {
}
let cids = await findCids(query, data.hardCap);
if (localOnly) {
cids = cids.filter(cid => utils.isNumber(cid));
}
const result = await plugins.hooks.fire('filter:categories.search', {
data: data,

View File

@@ -123,8 +123,9 @@ topicsController.get = async function getTopic(req, res, next) {
p => parseInt(p.index, 10) === parseInt(Math.max(0, postIndex - 1), 10)
);
const [author] = await Promise.all([
const [author, crossposts] = await Promise.all([
user.getUserFields(topicData.uid, ['username', 'userslug']),
topics.crossposts.get(topicData.tid),
buildBreadcrumbs(topicData),
addOldCategory(topicData, userPrivileges),
addTags(topicData, req, res, currentPage, postAtIndex),
@@ -134,6 +135,7 @@ topicsController.get = async function getTopic(req, res, next) {
]);
topicData.author = author;
topicData.crossposts = crossposts;
topicData.pagination = pagination.create(currentPage, pageCount, req.query);
topicData.pagination.rel.forEach((rel) => {
rel.href = `${url}/topic/${topicData.slug}${rel.href}`;

View File

@@ -213,3 +213,17 @@ Topics.move = async (req, res) => {
helpers.formatApiResponse(200, res);
};
Topics.crosspost = async (req, res) => {
const { cid } = req.body;
const crossposts = await topics.crossposts.add(req.params.tid, cid, req.uid);
helpers.formatApiResponse(200, res, { crossposts });
};
Topics.uncrosspost = async (req, res) => {
const { cid } = req.body;
const crossposts = await topics.crossposts.remove(req.params.tid, cid, req.uid);
helpers.formatApiResponse(200, res, { crossposts });
};

View File

@@ -54,5 +54,8 @@ module.exports = function () {
setupApiRoute(router, 'put', '/:tid/move', [...middlewares, middleware.assert.topic], controllers.write.topics.move);
setupApiRoute(router, 'post', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.crosspost);
setupApiRoute(router, 'delete', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.uncrosspost);
return router;
};

127
src/topics/crossposts.js Normal file
View File

@@ -0,0 +1,127 @@
'use strict';
const db = require('../database');
const topics = require('.');
const categories = require('../categories');
const posts = require('../posts');
const activitypub = require('../activitypub');
const utils = require('../utils');
const Crossposts = module.exports;
Crossposts.get = async function (tid) {
const crosspostIds = await db.getSortedSetMembers(`tid:${tid}:crossposts`);
let crossposts = await db.getObjects(crosspostIds.map(id => `crosspost:${id}`));
const cids = crossposts.reduce((cids, crossposts) => {
cids.add(crossposts.cid);
return cids;
}, new Set());
let categoriesData = await categories.getCategoriesFields(
cids, ['cid', 'name', 'icon', 'bgColor', 'color', 'slug']
);
categoriesData = categoriesData.reduce((map, category) => {
map.set(parseInt(category.cid, 10), category);
return map;
}, new Map());
crossposts = crossposts.map((crosspost, idx) => {
crosspost.id = crosspostIds[idx];
crosspost.category = categoriesData.get(parseInt(crosspost.cid, 10));
return crosspost;
});
return crossposts;
};
Crossposts.add = async function (tid, cid, uid) {
// Target cid must exist
if (!utils.isNumber(cid)) {
await activitypub.actors.assert(cid);
}
const exists = await categories.exists(cid);
if (!exists) {
throw new Error('[[error:invalid-cid]]');
}
const crossposts = await Crossposts.get(tid);
const crosspostedCids = crossposts.map(crosspost => String(crosspost.cid));
const now = Date.now();
const crosspostId = utils.generateUUID();
if (!crosspostedCids.includes(String(cid))) {
const [topicData, pids] = await Promise.all([
topics.getTopicFields(tid, ['uid', 'cid', 'timestamp']),
topics.getPids(tid),
]);
let pidTimestamps = await posts.getPostsFields(pids, ['timestamp']);
pidTimestamps = pidTimestamps.map(({ timestamp }) => timestamp);
if (cid === topicData.cid) {
throw new Error('[[error:invalid-cid]]');
}
const zsets = [
`cid:${topicData.cid}:tids`,
`cid:${topicData.cid}:tids:create`,
`cid:${topicData.cid}:tids:lastposttime`,
`cid:${topicData.cid}:uid:${topicData.uid}:tids`,
`cid:${topicData.cid}:tids:votes`,
`cid:${topicData.cid}:tids:posts`,
`cid:${topicData.cid}:tids:views`,
];
const scores = await db.sortedSetsScore(zsets, tid);
const bulkAdd = zsets.map((zset, idx) => {
return [zset.replace(`cid:${topicData.cid}`, `cid:${cid}`), scores[idx], tid];
});
await Promise.all([
db.sortedSetAddBulk(bulkAdd),
db.sortedSetAdd(`cid:${cid}:pids`, pidTimestamps, pids),
db.setObject(`crosspost:${crosspostId}`, { uid, tid, cid, timestamp: now }),
db.sortedSetAdd(`tid:${tid}:crossposts`, now, crosspostId),
db.sortedSetAdd(`uid:${uid}:crossposts`, now, crosspostId),
]);
await categories.onTopicsMoved([cid]);
} else {
throw new Error('[[error:topic-already-crossposted]]');
}
return [...crossposts, { id: crosspostId, uid, tid, cid, timestamp: now }];
};
Crossposts.remove = async function (tid, cid, uid) {
let crossposts = await Crossposts.get(tid);
const crosspostId = crossposts.reduce((id, { id: _id, cid: _cid, uid: _uid }) => {
if (String(cid) === String(_cid) && String(uid) === String(_uid)) {
id = _id;
}
return id;
}, null);
if (!crosspostId) {
throw new Error('[[error:invalid-data]]');
}
const [author, pids] = await Promise.all([
topics.getTopicField(tid, 'uid'),
topics.getPids(tid),
]);
let bulkRemove = [
`cid:${cid}:tids`,
`cid:${cid}:tids:create`,
`cid:${cid}:tids:lastposttime`,
`cid:${cid}:uid:${author}:tids`,
`cid:${cid}:tids:votes`,
`cid:${cid}:tids:posts`,
`cid:${cid}:tids:views`,
];
bulkRemove = bulkRemove.map(zset => [zset, tid]);
bulkRemove.push([`cid:${cid}:pids`, pids]);
await Promise.all([
db.sortedSetRemoveBulk(bulkRemove),
db.delete(`crosspost:${crosspostId}`),
db.sortedSetRemove(`tid:${tid}:crossposts`, crosspostId),
db.sortedSetRemove(`uid:${uid}:crossposts`, crosspostId),
]);
await categories.onTopicsMoved([cid]);
crossposts = await Crossposts.get(tid);
return crossposts;
};

View File

@@ -35,6 +35,7 @@ Topics.thumbs = require('./thumbs');
require('./bookmarks')(Topics);
require('./merge')(Topics);
Topics.events = require('./events');
Topics.crossposts = require('./crossposts');
Topics.exists = async function (tids) {
return await db.exists(

View File

@@ -5,9 +5,11 @@ const _ = require('lodash');
const db = require('../database');
const topics = require('.');
const categories = require('../categories');
const posts = require('../posts');
const user = require('../user');
const plugins = require('../plugins');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const utils = require('../utils');
@@ -233,7 +235,7 @@ module.exports = function (Topics) {
};
topicTools.move = async function (tid, data) {
const cid = utils.isNumber(data.cid) ? parseInt(data.cid, 10) : data.cid;
const cid = parseInt(data.cid, 10);
const topicData = await Topics.getTopicData(tid);
if (!topicData) {
throw new Error('[[error:no-topic]]');
@@ -241,6 +243,10 @@ module.exports = function (Topics) {
if (cid === topicData.cid) {
throw new Error('[[error:cant-move-topic-to-same-category]]');
}
if (!utils.isNumber(cid) || !utils.isNumber(topicData.cid)) {
throw new Error('[[error:cant-move-topic-to-from-remote-categories]]');
}
const tags = await Topics.getTopicTags(tid);
await db.sortedSetsRemove([
`cid:${topicData.cid}:tids`,

View File

@@ -16,12 +16,14 @@
</div>
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
{{{ if !hideAll }}}
<li role="presentation" class="category" data-cid="all">
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem" href="{{{ if allCategoriesUrl }}}{config.relative_path}/{allCategoriesUrl}{{{ else }}}#{{{ end }}}">
<div class="flex-grow-1">[[unread:all-categories]]</div>
<i component="category/select/icon" class="flex-shrink-0 fa fa-fw fa-check {{{if selectedCategory}}}invisible{{{end}}}"></i>
</a>
</li>
{{{ end }}}
{{{each categoryItems}}}
<li role="presentation" class="category {{{ if ./disabledClass }}}disabled{{{ end }}}" data-cid="{./cid}" data-parent-cid="{./parentCid}" data-name="{./name}">
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2 {{{ if ./disabledClass }}}disabled{{{ end }}}" role="menuitem" href="#">

View File

@@ -15,9 +15,15 @@
<a component="topic/unpin" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2 {{{ if !pinned }}}hidden{{{ end }}}" role="menuitem"><i class="fa fa-fw fa-thumb-tack fa-rotate-90 text-secondary"></i> [[topic:thread-tools.unpin]]</a>
</li>
{{{ if isNumber(cid) }}}
<li>
<a component="topic/move" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-arrows text-secondary"></i> [[topic:thread-tools.move]]</a>
</li>
{{{ end }}}
<li>
<a component="topic/crosspost" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-clone text-secondary"></i> [[topic:thread-tools.crosspost]]</a>
</li>
<li>
<a component="topic/merge" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-code-fork text-secondary"></i> [[topic:thread-tools.merge]]</a>

View File

@@ -659,10 +659,15 @@ describe('API', async () => {
case 'boolean':
assert.strictEqual(typeof response[prop], 'boolean', `"${prop}" was expected to be a boolean, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
break;
case 'object':
assert.strictEqual(typeof response[prop], 'object', `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
case 'object': {
let valid = ['object'];
if (schema[prop].additionalProperties && schema[prop].additionalProperties.oneOf) {
valid = schema[prop].additionalProperties.oneOf.map(({ type }) => type);
}
assert(valid.includes(typeof response[prop]), `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
compare(schema[prop], response[prop], method, path, context ? [context, prop].join('.') : prop);
break;
}
case 'array':
assert.strictEqual(Array.isArray(response[prop]), true, `"${prop}" was expected to be an array, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);

160
test/topics/crossposts.js Normal file
View File

@@ -0,0 +1,160 @@
'use strict';
const assert = require('assert');
const db = require('../mocks/databasemock');
const user = require('../../src/user');
const categories = require('../../src/categories');
const topics = require('../../src/topics');
const utils = require('../../src/utils');
describe('Crossposting (& related logic)', () => {
describe('topic already in multiple categories', () => {
let tid;
let cid1;
let cid2;
let uid;
before(async () => {
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
const { topicData } = await topics.post({
uid,
cid: cid1,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
tid = topicData.tid;
// Add topic to another category's zset
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
cid2 = crosspostCategory.cid;
await db.sortedSetAdd(`cid:${crosspostCategory.cid}:tids`, topicData.timestamp, tid);
});
it('should contain the topic in both categories when requested', async () => {
const tids1 = await categories.getTopicIds({
uid,
cid: cid1,
start: 0,
stop: 1,
});
const tids2 = await categories.getTopicIds({
uid,
cid: cid2,
start: 0,
stop: 1,
});
assert.deepStrictEqual(tids1, tids2);
});
});
describe('crosspost', () => {
let tid;
let cid1;
let cid2;
let uid;
before(async () => {
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
cid2 = crosspostCategory.cid;
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
const { topicData } = await topics.post({
uid,
cid: cid1,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
tid = topicData.tid;
});
it('should successfully crosspost to another cid', async () => {
const crossposts = await topics.crossposts.add(tid, cid2, uid);
assert(Array.isArray(crossposts));
assert.strictEqual(crossposts.length, 1);
assert.partialDeepStrictEqual(crossposts[0], {
uid,
tid,
cid: cid2,
});
});
it('should show the tid in both categories when requested', async () => {
const tids1 = await categories.getTopicIds({
uid,
cid: cid1,
start: 0,
stop: 1,
});
const tids2 = await categories.getTopicIds({
uid,
cid: cid2,
start: 0,
stop: 1,
});
assert.deepStrictEqual(tids1, tids2);
});
it('should throw on cross-posting again when already cross-posted', async () => {
await assert.rejects(
topics.crossposts.add(tid, cid2, uid),
{ message: '[[error:topic-already-crossposted]]' },
);
});
});
describe('uncrosspost', () => {
let tid;
let cid1;
let cid2;
let uid;
before(async () => {
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
cid2 = crosspostCategory.cid;
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
const { topicData } = await topics.post({
uid,
cid: cid1,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
tid = topicData.tid;
await topics.crossposts.add(tid, cid2, uid);
});
it('should successfully uncrosspost from a cid', async () => {
const crossposts = await topics.crossposts.remove(tid, cid2, uid);
assert(Array.isArray(crossposts));
assert.strictEqual(crossposts.length, 0);
});
it('should not contain the topic in the category the topic was uncrossposted from', async () => {
const tids = await categories.getTopicIds({
uid,
cid: cid2,
start: 0,
stop: 1,
});
assert(!tids.includes(tid));
});
it('should throw on uncrossposting if already uncrossposted', async () => {
assert.rejects(
topics.crossposts.remove(tid, cid2, uid),
'[[error:invalid-data]]',
);
});
});
});

109
test/topics/tools.js Normal file
View File

@@ -0,0 +1,109 @@
'use strict';
const assert = require('assert');
const db = require('../mocks/databasemock');
const user = require('../../src/user');
const categories = require('../../src/categories');
const topics = require('../../src/topics');
const utils = require('../../src/utils');
describe('Topic tools', () => {
describe('Topic moving', () => {
let cid1;
let cid2;
let tid;
let uid;
before(async () => {
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
({ cid: cid2 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
const { topicData } = await topics.post({
uid,
cid: cid1,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
tid = topicData.tid;
});
it('should not error when moving a topic from one cid to another', async () => {
await topics.tools.move(tid, {
cid: cid2,
uid,
});
});
it('should reflect the topic in the new category', async () => {
const tids = await categories.getTopicIds({
uid,
cid: cid2,
start: 0,
stop: 1,
});
assert(Array.isArray(tids));
assert.deepStrictEqual(tids, [String(tid)]);
});
it('should NOT reflect the topic in the old category', async () => {
const tids = await categories.getTopicIds({
uid,
cid: cid1,
start: 0,
stop: 1,
});
assert(Array.isArray(tids));
assert.deepStrictEqual(tids, []);
});
});
describe('with remote categories', () => {
let remoteCid;
let localCid;
let tid1;
let tid2;
before(async () => {
const helpers = require('../activitypub/helpers');
({ id: remoteCid } = helpers.mocks.group());
({ cid: localCid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
({ id: tid1 } = helpers.mocks.note({
audience: remoteCid,
}));
const uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
const { topicData } = await topics.post({
uid,
cid: localCid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
tid2 = topicData.tid;
});
it('should throw when attempting to move a topic from a remote category', async () => {
assert.rejects(
topics.tools.move(tid1, {
cid: localCid,
uid: 'system',
}),
'[[error:cant-move-topic-to-from-remote-categories]]'
);
});
it('should throw when attempting to move a topic to a remote category', async () => {
assert.rejects(
topics.tools.move(tid2, {
cid: remoteCid,
uid: 'system',
}),
'[[error:cant-move-topic-to-from-remote-categories]]'
);
});
});
});