mirror of
https://github.com/status-im/sourcecred.git
synced 2025-02-07 18:25:19 +00:00
Discourse: make MockFetcher API similar to real forum (#1473)
This extends the MockFetcher in the tests to provide new semantics update mode 2 relies on. They're based on the below changes to the Fetcher: - add categoryId and bumpedMs to Topic data #1454 - make topicWithPosts fetch all posts #1455 - add categoryDefinitionTopicIds to fetcher #1456 - implement topicsBumpedSince in fetcher #1457 Particularly because the addition of two new concepts (categories and category definition topics), the API of the MockFetcher got rather convoluted. This refactor makes it behave a lot more like you'd be familiar with within Discourse. Such as, creating a topic creates it's opening post as a side effect. Instead of a post with an unknown topic ID creating a topic as a side effect. And creating a category creates it's category definition topic as a side effect. Also, we're being a lot more explicit, using objects instead of positional arguments.
This commit is contained in:
parent
c521acc145
commit
890489c0d2
@ -1,11 +1,13 @@
|
||||
// @flow
|
||||
|
||||
import {max} from "d3-array";
|
||||
import sortBy from "lodash.sortby";
|
||||
import Database from "better-sqlite3";
|
||||
import {Mirror, type MirrorOptions} from "./mirror";
|
||||
import {SqliteMirrorRepository} from "./mirrorRepository";
|
||||
import {
|
||||
type Discourse,
|
||||
type CategoryId,
|
||||
type TopicId,
|
||||
type PostId,
|
||||
type Topic,
|
||||
@ -19,47 +21,102 @@ import * as MapUtil from "../../util/map";
|
||||
import * as NullUtil from "../../util/null";
|
||||
import {TestTaskReporter} from "../../util/taskReporter";
|
||||
|
||||
type PostInfo = {|
|
||||
+indexWithinTopic: number,
|
||||
+replyToPostIndex: number | null,
|
||||
+topicId: number,
|
||||
+authorUsername: string,
|
||||
+cooked: string,
|
||||
type TopicOptions = {|
|
||||
// Defaults to: 1 (synonymous to "uncategorized")
|
||||
categoryId?: CategoryId,
|
||||
// Defaults to: "credbot"
|
||||
authorUsername?: string,
|
||||
// Defaults to: "<h1>Hello World</h1>"
|
||||
cooked?: string,
|
||||
|};
|
||||
|
||||
type PostOptions = {|
|
||||
topicId: TopicId,
|
||||
// Defaults to: null
|
||||
replyToPostIndex?: PostId,
|
||||
// Defaults to: "credbot"
|
||||
authorUsername?: string,
|
||||
// Defaults to: "<h1>Hello World</h1>"
|
||||
cooked?: string,
|
||||
|};
|
||||
|
||||
type PostEdits = {|
|
||||
// Defaults to: <no change>
|
||||
replyToPostIndex?: PostId,
|
||||
// Defaults to: <no change>
|
||||
timestampMs?: number,
|
||||
// Defaults to: <no change>
|
||||
cooked?: string,
|
||||
|};
|
||||
|
||||
type CreatedCategory = {|
|
||||
+categoryId: CategoryId,
|
||||
+topicId: TopicId,
|
||||
+postId: PostId,
|
||||
|};
|
||||
|
||||
type CreatedTopic = {|
|
||||
+topicId: TopicId,
|
||||
+postId: PostId,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A class which we can use to store and retrieve data which will act like
|
||||
* Discourses' internal data structure. We can use this to mock data for tests
|
||||
* and implement the Fetcher on top of.
|
||||
*
|
||||
* It requires you to add data in the same order as you would on an actual forum.
|
||||
* So to create a post, first a topic should exist to post in.
|
||||
* Creating a topic also creates an opening post.
|
||||
* To add a topic to a category, first you must create it.
|
||||
* Creating a category also creates a category definition topic.
|
||||
*/
|
||||
class MockFetcher implements Discourse {
|
||||
_latestTopicId: number;
|
||||
_latestPostId: number;
|
||||
_nextCategoryId: CategoryId;
|
||||
_nextTopicId: TopicId;
|
||||
_nextPostId: PostId;
|
||||
_categories: Set<CategoryId>;
|
||||
_categoryDefinitionTopics: Set<TopicId>;
|
||||
_topicToCategory: Map<TopicId, CategoryId>;
|
||||
_topicToPostIds: Map<TopicId, PostId[]>;
|
||||
_posts: Map<PostId, PostInfo>;
|
||||
_posts: Map<PostId, Post>;
|
||||
_likes: LikeAction[];
|
||||
|
||||
constructor() {
|
||||
this._latestTopicId = 1;
|
||||
this._latestPostId = 1;
|
||||
// Start with 2, as category ID 1 is reserved as "uncategorized".
|
||||
this._nextCategoryId = 2;
|
||||
this._nextTopicId = 1;
|
||||
this._nextPostId = 1;
|
||||
this._categories = new Set();
|
||||
this._categoryDefinitionTopics = new Set();
|
||||
this._topicToCategory = new Map();
|
||||
this._topicToPostIds = new Map();
|
||||
this._posts = new Map();
|
||||
this._likes = [];
|
||||
}
|
||||
|
||||
async categoryDefinitionTopicIds(): Promise<Set<TopicId>> {
|
||||
throw new Error("Method should not be used yet by mirror");
|
||||
return this._categoryDefinitionTopics;
|
||||
}
|
||||
|
||||
async topicsBumpedSince(_unused_sinceMs: number): Promise<TopicLatest[]> {
|
||||
throw new Error("Method should not be used yet by mirror");
|
||||
async topicsBumpedSince(sinceMs: number): Promise<TopicLatest[]> {
|
||||
return Array.from(this._topicToPostIds.keys())
|
||||
.filter((tid) => !this._categoryDefinitionTopics.has(tid))
|
||||
.map((tid) => this._topicLatest(tid))
|
||||
.filter((t) => t.bumpedMs > sinceMs);
|
||||
}
|
||||
|
||||
async latestTopicId(): Promise<TopicId> {
|
||||
return this._latestTopicId;
|
||||
if (this._topicToPostIds.size == 0) {
|
||||
throw new Error("Called latestTopicId before adding any topics.");
|
||||
}
|
||||
|
||||
return this._nextTopicId - 1;
|
||||
}
|
||||
|
||||
async latestPosts(): Promise<Post[]> {
|
||||
const latestPost = this._post(this._latestPostId - 1);
|
||||
if (latestPost == null) {
|
||||
return [];
|
||||
}
|
||||
return [latestPost];
|
||||
const latest = this._posts.get(this._nextPostId - 1);
|
||||
return latest == null ? [] : [latest];
|
||||
}
|
||||
|
||||
async topicWithPosts(id: TopicId): Promise<TopicWithPosts | null> {
|
||||
@ -67,13 +124,15 @@ class MockFetcher implements Discourse {
|
||||
if (postIds == null || postIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const firstPost = this._post(postIds[0]);
|
||||
if (firstPost == null) {
|
||||
throw new Error("invalid firstPost");
|
||||
}
|
||||
// Only return the first post in the posts array, to ensure that we have to
|
||||
// test the functionality where we manually grab posts by ID
|
||||
const posts = [firstPost];
|
||||
const posts = postIds.map((pid) => {
|
||||
const post = this._post(pid);
|
||||
if (post == null) {
|
||||
throw new Error(
|
||||
`MockFetcher implementation bug, mapped topic ${id} to a post ${pid} that doesn't exist.`
|
||||
);
|
||||
}
|
||||
return post;
|
||||
});
|
||||
return {topic: this._topicView(id), posts};
|
||||
}
|
||||
|
||||
@ -89,19 +148,48 @@ class MockFetcher implements Discourse {
|
||||
}
|
||||
|
||||
_topic(id: TopicId): Topic {
|
||||
return {
|
||||
...this._topicView(id),
|
||||
bumpedMs: 1000,
|
||||
};
|
||||
const view = this._topicView(id);
|
||||
|
||||
// Note: in theory this should always be a non-empty array and not produce null posts.
|
||||
// As _topicToPostIds shouldn't contain post IDs that weren't added and a Topic
|
||||
// should always have an opening post.
|
||||
const postIds = this._topicToPostIds.get(id) || [];
|
||||
const posts = NullUtil.filterList(postIds.map((pid) => this._post(pid)));
|
||||
|
||||
let bumpedMs = view.timestampMs;
|
||||
// Category definition topics don't support bumping, as they are never
|
||||
// in any API responses that contain this field.
|
||||
if (!this._categoryDefinitionTopics.has(id)) {
|
||||
bumpedMs = max(posts, (p) => p.timestampMs);
|
||||
}
|
||||
|
||||
return {...view, bumpedMs};
|
||||
}
|
||||
|
||||
_topicLatest(topicId: TopicId): TopicLatest {
|
||||
const {id, title, timestampMs, categoryId, bumpedMs} = this._topic(topicId);
|
||||
return {id, title, timestampMs, categoryId, bumpedMs};
|
||||
}
|
||||
|
||||
_topicView(id: TopicId): TopicView {
|
||||
if (!this._topicToPostIds.has(id)) {
|
||||
throw new Error(`Topic with ID ${id} not yet added.`);
|
||||
}
|
||||
|
||||
// Find the opening post, as it defines the topic author.
|
||||
const opId = (this._topicToPostIds.get(id) || [])[0] || 0;
|
||||
const openingPost = this._post(opId);
|
||||
if (!openingPost) {
|
||||
throw new Error(`Topic with ID ${id} has no opening post.`);
|
||||
}
|
||||
|
||||
const timestampMs = 1000 + id;
|
||||
return {
|
||||
id,
|
||||
categoryId: 1,
|
||||
categoryId: this._topicToCategory.get(id) || 1,
|
||||
title: `topic ${id}`,
|
||||
timestampMs: 1000,
|
||||
authorUsername: "credbot",
|
||||
timestampMs,
|
||||
authorUsername: openingPost.authorUsername,
|
||||
};
|
||||
}
|
||||
|
||||
@ -110,63 +198,159 @@ class MockFetcher implements Discourse {
|
||||
}
|
||||
|
||||
_post(id: PostId): Post | null {
|
||||
const postInfo = this._posts.get(id);
|
||||
if (postInfo == null) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
replyToPostIndex,
|
||||
topicId,
|
||||
indexWithinTopic,
|
||||
authorUsername,
|
||||
cooked,
|
||||
} = postInfo;
|
||||
return {
|
||||
id,
|
||||
timestampMs: 2003,
|
||||
replyToPostIndex,
|
||||
topicId,
|
||||
indexWithinTopic,
|
||||
authorUsername,
|
||||
cooked,
|
||||
};
|
||||
return this._posts.get(id) || null;
|
||||
}
|
||||
|
||||
addPost(
|
||||
topicId: TopicId,
|
||||
replyToNumber: number | null,
|
||||
username?: string,
|
||||
cooked?: string
|
||||
): PostId {
|
||||
const postId = this._latestPostId++;
|
||||
this._latestTopicId = Math.max(topicId, this._latestTopicId);
|
||||
/**
|
||||
* Adds a new Category.
|
||||
* This will create a category definition topic as well.
|
||||
* The definition topic will create an opening post.
|
||||
*
|
||||
* Note: does not have any perception of sub-categories. None of the current
|
||||
* fetcher methods make the distinction so supporting it here is unnecessary.
|
||||
*/
|
||||
addCategory(topic?: $Shape<TopicOptions>): CreatedCategory {
|
||||
// Have the new Category exist.
|
||||
const categoryId = this._nextCategoryId;
|
||||
this._nextCategoryId++;
|
||||
this._categories.add(categoryId);
|
||||
|
||||
// Add the Topic and Post.
|
||||
const {topicId, postId} = this.addTopic({...topic, categoryId});
|
||||
|
||||
// Update the mapping, now we know the category definition topic ID.
|
||||
this._categoryDefinitionTopics.add(topicId);
|
||||
return {categoryId, topicId, postId};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new Topic.
|
||||
* The topic will create an opening post.
|
||||
*
|
||||
* Note: to set a categoryId, that category must be added first.
|
||||
*/
|
||||
addTopic(topic?: TopicOptions): CreatedTopic {
|
||||
const {categoryId, authorUsername, cooked} = topic || {};
|
||||
if (categoryId === 1) {
|
||||
throw new Error(
|
||||
`Category with ID 1 is reserved as uncategorized, please don't explicitly define it.`
|
||||
);
|
||||
}
|
||||
if (categoryId && !this._categories.has(categoryId)) {
|
||||
throw new Error(`Category with ID ${categoryId} not yet added.`);
|
||||
}
|
||||
|
||||
// Have the new Topic exist through an empty PostId[].
|
||||
const topicId = this._nextTopicId;
|
||||
this._nextTopicId++;
|
||||
this._topicToPostIds.set(topicId, []);
|
||||
|
||||
// Add to category.
|
||||
if (categoryId != undefined) {
|
||||
this._topicToCategory.set(topicId, categoryId);
|
||||
}
|
||||
|
||||
// Add the Post.
|
||||
const postId = this.addPost({topicId, authorUsername, cooked});
|
||||
|
||||
return {topicId, postId};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new Post.
|
||||
*
|
||||
* Note: a Topic to add this Post to must be added first.
|
||||
*/
|
||||
addPost({
|
||||
topicId,
|
||||
replyToPostIndex,
|
||||
authorUsername,
|
||||
cooked,
|
||||
}: PostOptions): PostId {
|
||||
if (!this._topicToPostIds.has(topicId)) {
|
||||
throw new Error(`Topic with ID ${topicId} not yet added.`);
|
||||
}
|
||||
|
||||
// Add the post.
|
||||
const postId = this._nextPostId;
|
||||
this._nextPostId++;
|
||||
|
||||
// Push post into the mapping.
|
||||
const postsOnTopic = MapUtil.pushValue(
|
||||
this._topicToPostIds,
|
||||
topicId,
|
||||
postId
|
||||
);
|
||||
if (replyToNumber != null && replyToNumber >= postsOnTopic.length) {
|
||||
throw new Error("invalid replyToNumber");
|
||||
|
||||
// Make sure the replyToPostIndex exists.
|
||||
if (replyToPostIndex != null && replyToPostIndex >= postsOnTopic.length) {
|
||||
throw new Error(
|
||||
`Invalid replyToPostIndex ${replyToPostIndex}, given ${postsOnTopic.length} in Topid ID ${topicId}`
|
||||
);
|
||||
}
|
||||
const postInfo: PostInfo = {
|
||||
indexWithinTopic: postsOnTopic.length,
|
||||
replyToPostIndex: replyToNumber,
|
||||
|
||||
const post: Post = {
|
||||
id: postId,
|
||||
topicId: topicId,
|
||||
authorUsername: NullUtil.orElse(username, "credbot"),
|
||||
cooked: NullUtil.orElse(cooked, "<h1>Hello World</h1>"),
|
||||
timestampMs: 2000 + postId,
|
||||
indexWithinTopic: postsOnTopic.length,
|
||||
replyToPostIndex: replyToPostIndex || null,
|
||||
authorUsername: authorUsername || "credbot",
|
||||
cooked: cooked || "<h1>Hello World</h1>",
|
||||
};
|
||||
this._posts.set(postId, postInfo);
|
||||
this._posts.set(postId, post);
|
||||
return postId;
|
||||
}
|
||||
|
||||
addLike(actingUser: string, postId: PostId, timestampMs: number): LikeAction {
|
||||
if (!this._posts.has(postId)) {
|
||||
throw new Error("bad postId");
|
||||
/**
|
||||
* Adds a LikeAction.
|
||||
*
|
||||
* Note: a Post to add this LikeAction to must be added first.
|
||||
*/
|
||||
addLike(action: LikeAction) {
|
||||
if (!this._posts.has(action.postId)) {
|
||||
throw new Error(`Post with ID ${action.postId} not yet added.`);
|
||||
}
|
||||
const action = {username: actingUser, postId, timestampMs};
|
||||
this._likes.push(action);
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a post.
|
||||
*
|
||||
* Only supports a limited range of edits, which is expressed by the
|
||||
* PostEdits type. Other operations would cause complex consistency
|
||||
* issues. Like moving to a different topic.
|
||||
*/
|
||||
editPost(postId: PostId, changes: PostEdits): void {
|
||||
const oldPost = this._posts.get(postId);
|
||||
if (oldPost == null) {
|
||||
throw new Error(`Trying to change post ${postId} which doesn't exist.`);
|
||||
}
|
||||
const newPost = {...oldPost, ...changes};
|
||||
this._posts.set(postId, newPost);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a post.
|
||||
*/
|
||||
deletePost(postId: PostId) {
|
||||
const oldPost = this._posts.get(postId);
|
||||
if (oldPost == null) {
|
||||
throw new Error(`Trying to delete post ${postId} which doesn't exist.`);
|
||||
}
|
||||
const postList = this._topicToPostIds.get(oldPost.topicId);
|
||||
if (postList == null) {
|
||||
throw new Error(
|
||||
`MockFetcher implementation bug, deleting post ${postId} which doesn't have it's topic linking back to it.`
|
||||
);
|
||||
}
|
||||
this._topicToPostIds.set(
|
||||
oldPost.topicId,
|
||||
postList.filter((pid) => pid != postId)
|
||||
);
|
||||
this._posts.delete(postId);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMode1Topics(topics: Topic[]): Topic[] {
|
||||
@ -191,6 +375,7 @@ describe("plugins/discourse/mirror", () => {
|
||||
spyWarn().mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
const example = (optionOverrides?: $Shape<MirrorOptions>) => {
|
||||
// Explicitly set all options, so we know what to expect in tests.
|
||||
const options: MirrorOptions = {
|
||||
@ -212,37 +397,96 @@ describe("plugins/discourse/mirror", () => {
|
||||
describe("mirror mode 1", () => {
|
||||
it("mirrors topics from the fetcher", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(2, null);
|
||||
fetcher.addPost(3, null);
|
||||
const topic2 = fetcher._topic(2);
|
||||
const topic3 = fetcher._topic(3);
|
||||
const t1 = fetcher.addTopic();
|
||||
const t2 = fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
expect(repo.topics()).toEqual(normalizeMode1Topics([topic2, topic3]));
|
||||
expect(repo.topics()).toEqual(
|
||||
normalizeMode1Topics([
|
||||
fetcher._topic(t1.topicId),
|
||||
fetcher._topic(t2.topicId),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors category definition topics from the fetcher", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const c1 = fetcher.addCategory();
|
||||
const t2 = fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
expect(repo.topics()).toEqual(
|
||||
normalizeMode1Topics([
|
||||
fetcher._topic(c1.topicId),
|
||||
fetcher._topic(t2.topicId),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors topics in a category from the fetcher", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const c1 = fetcher.addCategory();
|
||||
const t2 = fetcher.addTopic({categoryId: c1.categoryId});
|
||||
await mirror.update(reporter);
|
||||
expect(repo.topics()).toEqual(
|
||||
normalizeMode1Topics([
|
||||
fetcher._topic(c1.topicId),
|
||||
fetcher._topic(t2.topicId),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors posts from the fetcher", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const p1 = fetcher.addPost(2, null);
|
||||
const p2 = fetcher.addPost(3, null);
|
||||
const p3 = fetcher.addPost(3, 1);
|
||||
const t1 = fetcher.addTopic();
|
||||
const t2 = fetcher.addTopic();
|
||||
const [p3] = [fetcher.addPost({topicId: t2.topicId})];
|
||||
await mirror.update(reporter);
|
||||
const posts = [fetcher._post(p1), fetcher._post(p2), fetcher._post(p3)];
|
||||
const posts = [
|
||||
fetcher._post(t1.postId),
|
||||
fetcher._post(t2.postId),
|
||||
fetcher._post(p3),
|
||||
];
|
||||
expect(repo.posts()).toEqual(posts);
|
||||
});
|
||||
|
||||
it("mirrors edited posts from the fetcher", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const t1 = fetcher.addTopic();
|
||||
const t2 = fetcher.addTopic();
|
||||
const p3 = fetcher.addPost({topicId: t2.topicId});
|
||||
fetcher.editPost(p3, {cooked: "<p>Here's some edited content.</p>"});
|
||||
await mirror.update(reporter);
|
||||
expect(repo.posts()).toEqual([
|
||||
fetcher._post(t1.postId),
|
||||
fetcher._post(t2.postId),
|
||||
fetcher._post(p3),
|
||||
]);
|
||||
expect(repo.posts()[2].cooked).toEqual(
|
||||
"<p>Here's some edited content.</p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors remainder when a post is deleted from the fetcher", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const t1 = fetcher.addTopic();
|
||||
const t2 = fetcher.addTopic();
|
||||
const p3 = fetcher.addPost({topicId: t2.topicId});
|
||||
fetcher.deletePost(p3);
|
||||
await mirror.update(reporter);
|
||||
expect(repo.posts()).toEqual([
|
||||
fetcher._post(t1.postId),
|
||||
fetcher._post(t2.postId),
|
||||
]);
|
||||
});
|
||||
|
||||
it("provides usernames for all active users", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(2, null, "alpha");
|
||||
fetcher.addPost(3, null, "beta");
|
||||
fetcher.addPost(3, 1, "alpha");
|
||||
const [_, t2] = [
|
||||
fetcher.addTopic({authorUsername: "alpha"}),
|
||||
fetcher.addTopic({authorUsername: "beta"}),
|
||||
];
|
||||
fetcher.addPost({authorUsername: "delta", topicId: t2.topicId});
|
||||
await mirror.update(reporter);
|
||||
// credbot appears because it is the nominal author of all topics
|
||||
expect(
|
||||
repo
|
||||
.users()
|
||||
.slice()
|
||||
.sort()
|
||||
).toEqual(["alpha", "beta", "credbot"]);
|
||||
expect([...repo.users()].sort()).toEqual(["alpha", "beta", "delta"]);
|
||||
});
|
||||
|
||||
function expectLikesSorted(as, bs) {
|
||||
@ -257,37 +501,49 @@ describe("plugins/discourse/mirror", () => {
|
||||
|
||||
it("provides all the likes by users that have posted", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null, "alpha");
|
||||
fetcher.addPost(2, null, "alpha");
|
||||
fetcher.addPost(3, null, "beta");
|
||||
const l1 = fetcher.addLike("beta", 1, 5);
|
||||
const l2 = fetcher.addLike("beta", 2, 6);
|
||||
const l3 = fetcher.addLike("beta", 3, 7);
|
||||
const l4 = fetcher.addLike("alpha", 1, 8);
|
||||
const [t1, t2, t3] = [
|
||||
fetcher.addTopic({authorUsername: "alpha"}),
|
||||
fetcher.addTopic({authorUsername: "alpha"}),
|
||||
fetcher.addTopic({authorUsername: "beta"}),
|
||||
];
|
||||
const [l1, l2, l3, l4] = [
|
||||
fetcher.addLike({username: "beta", postId: t1.postId, timestampMs: 5}),
|
||||
fetcher.addLike({username: "beta", postId: t2.postId, timestampMs: 6}),
|
||||
fetcher.addLike({username: "beta", postId: t3.postId, timestampMs: 7}),
|
||||
fetcher.addLike({username: "alpha", postId: t1.postId, timestampMs: 8}),
|
||||
];
|
||||
await mirror.update(reporter);
|
||||
expectLikesSorted(repo.likes(), [l1, l2, l3, l4]);
|
||||
const l5 = fetcher.addLike("alpha", 2, 9);
|
||||
fetcher.addPost(4, null, "credbot");
|
||||
const l6 = fetcher.addLike("credbot", 2, 10);
|
||||
const l7 = fetcher.addLike("beta", 4, 11);
|
||||
|
||||
const t4 = fetcher.addTopic({authorUsername: "credbot"});
|
||||
const [l5, l6, l7] = [
|
||||
fetcher.addLike({username: "alpha", postId: t2.postId, timestampMs: 9}),
|
||||
fetcher.addLike({
|
||||
username: "credbot",
|
||||
postId: t2.postId,
|
||||
timestampMs: 10,
|
||||
}),
|
||||
fetcher.addLike({username: "beta", postId: t4.postId, timestampMs: 11}),
|
||||
];
|
||||
|
||||
await mirror.update(reporter);
|
||||
expectLikesSorted(repo.likes(), [l1, l2, l3, l4, l5, l6, l7]);
|
||||
});
|
||||
|
||||
it("doesn't find likes of users that never posted", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
fetcher.addLike("nope", 1, 1);
|
||||
const t1 = fetcher.addTopic();
|
||||
fetcher.addLike({username: "nope", postId: t1.postId, timestampMs: 1});
|
||||
await mirror.update(reporter);
|
||||
expect(repo.likes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("only fetches new topics on `update`", async () => {
|
||||
it("should not fetch existing topics when adding a new one on second `update`", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
fetcher.addPost(2, null);
|
||||
fetcher.addTopic();
|
||||
fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
fetcher.addPost(3, null);
|
||||
fetcher.addTopic();
|
||||
const fetchTopicWithPosts = jest.spyOn(fetcher, "topicWithPosts");
|
||||
await mirror.update(reporter);
|
||||
expect(fetchTopicWithPosts).toHaveBeenCalledTimes(1);
|
||||
@ -297,62 +553,70 @@ describe("plugins/discourse/mirror", () => {
|
||||
|
||||
it("gets new posts on old topics on update", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
fetcher.addPost(2, null);
|
||||
const t1 = fetcher.addTopic();
|
||||
fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
const id = fetcher.addPost(1, 1);
|
||||
fetcher.addPost(3, null);
|
||||
const p3 = fetcher.addPost({topicId: t1.topicId, replyToPostIndex: 1});
|
||||
fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
const latestPosts = await fetcher.latestPosts();
|
||||
// The post added to the old topic wasn't retrieved by latest post
|
||||
expect(latestPosts.map((x) => x.id)).not.toContain(id);
|
||||
expect(latestPosts.map((x) => x.id)).not.toContain(p3);
|
||||
const allPostIds = repo.posts().map((x) => x.id);
|
||||
// The post was still included, meaning the mirror scanned for new posts by id
|
||||
expect(allPostIds).toContain(id);
|
||||
expect(allPostIds).toContain(p3);
|
||||
});
|
||||
|
||||
it("skips null/missing topics", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
fetcher.addPost(3, null);
|
||||
fetcher.addTopic();
|
||||
// Force the internal counter to skip an ID.
|
||||
fetcher._nextTopicId++;
|
||||
fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
expect(repo.topics().map((x) => x.id)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("skips null/missing posts", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const p1 = fetcher.addPost(1, null);
|
||||
fetcher._latestPostId += 2;
|
||||
const p2 = fetcher.addPost(3, null);
|
||||
const t1 = fetcher.addTopic();
|
||||
// Force the internal counter to skip an ID.
|
||||
fetcher._nextPostId++;
|
||||
const t2 = fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
expect(repo.posts().map((x) => x.id)).toEqual([p1, p2]);
|
||||
expect(repo.posts().map((x) => x.id)).toEqual([t1.postId, t2.postId]);
|
||||
});
|
||||
|
||||
it("queries explicitly for posts that are not present in topicWithPosts.posts", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const p1 = fetcher.addPost(1, null);
|
||||
const p2 = fetcher.addPost(1, 1);
|
||||
const p3 = fetcher.addPost(1, 1);
|
||||
// As topicWithPosts.posts now returns all posts in the topic (#1455),
|
||||
// test this by skipping an ID and spy to see the ID in between is fetched.
|
||||
const t1 = fetcher.addTopic();
|
||||
const p2 = fetcher.addPost({topicId: t1.topicId});
|
||||
// Force the internal counter to skip an ID.
|
||||
fetcher._nextPostId++;
|
||||
const p3 = fetcher.addPost({topicId: t1.topicId});
|
||||
const fetchPost = jest.spyOn(fetcher, "post");
|
||||
await mirror.update(reporter);
|
||||
const getId = (x) => x.id;
|
||||
|
||||
const postsFromTopic = NullUtil.get(await fetcher.topicWithPosts(1))
|
||||
.posts;
|
||||
expect(postsFromTopic.map(getId)).toEqual([p1]);
|
||||
const {posts: postsFromTopic} = NullUtil.get(
|
||||
await fetcher.topicWithPosts(t1.topicId)
|
||||
);
|
||||
expect(postsFromTopic.map(getId)).toEqual([t1.postId, p2, p3]);
|
||||
|
||||
const postsFromLatest = await fetcher.latestPosts();
|
||||
expect(postsFromLatest.map(getId)).toEqual([p3]);
|
||||
|
||||
expect(fetchPost).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPost).toHaveBeenCalledWith(p2);
|
||||
expect(fetchPost).toHaveBeenCalledWith(p3 - 1);
|
||||
|
||||
expect(repo.posts().map(getId)).toEqual([p1, p2, p3]);
|
||||
expect(repo.posts().map(getId)).toEqual([t1.postId, p2, p3]);
|
||||
});
|
||||
|
||||
it("does not explicitly query for posts that were in topicWithPosts.posts", async () => {
|
||||
const {mirror, fetcher, reporter} = example();
|
||||
fetcher.addPost(1, null);
|
||||
fetcher.addTopic();
|
||||
const fetchPost = jest.spyOn(fetcher, "post");
|
||||
await mirror.update(reporter);
|
||||
expect(fetchPost).not.toHaveBeenCalled();
|
||||
@ -360,18 +624,18 @@ describe("plugins/discourse/mirror", () => {
|
||||
|
||||
it("does not explicitly query for posts that were provided in latest posts", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
const t1 = fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
const id = fetcher.addPost(1, 1);
|
||||
const p2 = fetcher.addPost({topicId: t1.topicId, replyToPostIndex: 1});
|
||||
const fetchPost = jest.spyOn(fetcher, "post");
|
||||
await mirror.update(reporter);
|
||||
expect(fetchPost).not.toHaveBeenCalled();
|
||||
expect(repo.posts().map((x) => x.id)).toContain(id);
|
||||
expect(repo.posts().map((x) => x.id)).toContain(p2);
|
||||
});
|
||||
|
||||
it("does not query for topics at all if there were no new topics", async () => {
|
||||
const {mirror, fetcher, reporter} = example();
|
||||
fetcher.addPost(1, null);
|
||||
fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
const fetchTopic = jest.spyOn(fetcher, "topicWithPosts");
|
||||
await mirror.update(reporter);
|
||||
@ -380,22 +644,23 @@ describe("plugins/discourse/mirror", () => {
|
||||
|
||||
it("queries for likes for every user", async () => {
|
||||
const {mirror, fetcher, reporter} = example();
|
||||
fetcher.addPost(1, null, "alpha");
|
||||
fetcher.addTopic({authorUsername: "alpha"});
|
||||
const fetchLikes = jest.spyOn(fetcher, "likesByUser");
|
||||
await mirror.update(reporter);
|
||||
expect(fetchLikes).toHaveBeenCalledTimes(2);
|
||||
expect(fetchLikes).toHaveBeenCalledWith("credbot", 0);
|
||||
expect(fetchLikes).toHaveBeenCalledTimes(1);
|
||||
expect(fetchLikes).toHaveBeenCalledWith("alpha", 0);
|
||||
});
|
||||
|
||||
it("queries with offset, as needed", async () => {
|
||||
const {mirror, fetcher, reporter} = example();
|
||||
fetcher.addPost(1, null, "credbot");
|
||||
fetcher.addPost(2, null, "credbot");
|
||||
fetcher.addPost(3, null, "credbot");
|
||||
fetcher.addLike("credbot", 1, 1);
|
||||
fetcher.addLike("credbot", 2, 2);
|
||||
fetcher.addLike("credbot", 3, 3);
|
||||
const [t1, t2, t3] = [
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
];
|
||||
fetcher.addLike({username: "credbot", postId: t1.postId, timestampMs: 1});
|
||||
fetcher.addLike({username: "credbot", postId: t2.postId, timestampMs: 2});
|
||||
fetcher.addLike({username: "credbot", postId: t3.postId, timestampMs: 3});
|
||||
const fetchLikes = jest.spyOn(fetcher, "likesByUser");
|
||||
await mirror.update(reporter);
|
||||
expect(fetchLikes).toHaveBeenCalledTimes(2);
|
||||
@ -405,13 +670,15 @@ describe("plugins/discourse/mirror", () => {
|
||||
|
||||
it("ceases querying once it has found all the new likes", async () => {
|
||||
const {mirror, fetcher, reporter} = example();
|
||||
fetcher.addPost(1, null, "credbot");
|
||||
fetcher.addPost(2, null, "credbot");
|
||||
fetcher.addPost(3, null, "credbot");
|
||||
fetcher.addLike("credbot", 1, 1);
|
||||
fetcher.addLike("credbot", 2, 2);
|
||||
const [t1, t2, t3] = [
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
];
|
||||
fetcher.addLike({username: "credbot", postId: t1.postId, timestampMs: 1});
|
||||
fetcher.addLike({username: "credbot", postId: t2.postId, timestampMs: 2});
|
||||
await mirror.update(reporter);
|
||||
fetcher.addLike("credbot", 3, 3);
|
||||
fetcher.addLike({username: "credbot", postId: t3.postId, timestampMs: 3});
|
||||
const fetchLikes = jest.spyOn(fetcher, "likesByUser");
|
||||
await mirror.update(reporter);
|
||||
expect(fetchLikes).toHaveBeenCalledTimes(1);
|
||||
@ -419,18 +686,33 @@ describe("plugins/discourse/mirror", () => {
|
||||
});
|
||||
|
||||
it("warns if one of the latest posts has no topic", async () => {
|
||||
/*
|
||||
What this test does, is to create topic 1 and 2.
|
||||
The order of posts will then be:
|
||||
- T1's opening post
|
||||
- T2's opening post
|
||||
|
||||
Next by lowering the _nextTopicId counter we fool the
|
||||
mock fetcher into returning T1 as the latest topic ID.
|
||||
Meaning T2 won't be found.
|
||||
|
||||
However, because T2's opening post is the most recent
|
||||
post, it should be returned by .latestPosts().
|
||||
*/
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const pid1 = fetcher.addPost(1, null, "credbot");
|
||||
const pid2 = fetcher.addPost(2, null, "credbot");
|
||||
const [t1, t2] = [
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
];
|
||||
// Verify that the problem post is one of the latest posts
|
||||
const latestPostIds = (await fetcher.latestPosts()).map((x) => x.id);
|
||||
expect(latestPostIds).toContain(pid2);
|
||||
// Force the fetcher not to return topic 2
|
||||
fetcher._latestTopicId--;
|
||||
expect(latestPostIds).toContain(t2.postId);
|
||||
// Force the fetcher not to return topic 2 as the latest.
|
||||
fetcher._nextTopicId--;
|
||||
await mirror.update(reporter);
|
||||
const topics = normalizeMode1Topics([fetcher._topic(1)]);
|
||||
expect(repo.topics()).toEqual(topics);
|
||||
const posts = [fetcher._post(pid1)];
|
||||
const posts = [fetcher._post(t1.postId)];
|
||||
expect(repo.posts()).toEqual(posts);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
"Warning: Encountered error 'FOREIGN KEY constraint failed' " +
|
||||
@ -441,19 +723,36 @@ describe("plugins/discourse/mirror", () => {
|
||||
});
|
||||
|
||||
it("warns if it finds a (non-latest) post with no topic", async () => {
|
||||
/*
|
||||
What this test does, is to create topic 1 and 2.
|
||||
Next it adds a reply to topic 1.
|
||||
The order of posts will then be:
|
||||
- T1's opening post
|
||||
- T2's opening post
|
||||
- Reply to T1
|
||||
|
||||
Next by lowering the _nextTopicId counter we fool the
|
||||
mock fetcher into returning T1 as the latest topic ID.
|
||||
Meaning T2 won't be found.
|
||||
|
||||
Additionally the opening post for T2 won't be the latest
|
||||
post, as the reply to T1 came after that.
|
||||
*/
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const pid1 = fetcher.addPost(1, null, "credbot");
|
||||
const pid2 = fetcher.addPost(2, null, "credbot");
|
||||
const pid3 = fetcher.addPost(1, null, "credbot");
|
||||
const [t1, t2] = [
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
fetcher.addTopic({authorUsername: "credbot"}),
|
||||
];
|
||||
const p3 = fetcher.addPost({topicId: t1.topicId, replyToPostIndex: 1});
|
||||
// Verify that the problem post is not one of the latest posts
|
||||
const latestPostIds = (await fetcher.latestPosts()).map((x) => x.id);
|
||||
expect(latestPostIds).not.toContain(pid2);
|
||||
// Force the fetcher not to return topic 2
|
||||
fetcher._latestTopicId--;
|
||||
expect(latestPostIds).not.toContain(t2.postId);
|
||||
// Force the fetcher not to return topic 2 as the latest.
|
||||
fetcher._nextTopicId--;
|
||||
await mirror.update(reporter);
|
||||
const topics = normalizeMode1Topics([fetcher._topic(1)]);
|
||||
expect(repo.topics()).toEqual(topics);
|
||||
const posts = [pid1, pid3].map((x) => fetcher._post(x));
|
||||
const posts = [t1.postId, p3].map((x) => fetcher._post(x));
|
||||
expect(repo.posts()).toEqual(posts);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
"Warning: Encountered error 'FOREIGN KEY constraint failed' " +
|
||||
@ -465,13 +764,13 @@ describe("plugins/discourse/mirror", () => {
|
||||
|
||||
it("warns if it gets a like that doesn't correspond to any post", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const pid = fetcher.addPost(1, null, "credbot");
|
||||
const t1 = fetcher.addTopic({authorUsername: "credbot"});
|
||||
const badLike = {username: "credbot", postId: 37, timestampMs: 0};
|
||||
fetcher._likes.push(badLike);
|
||||
await mirror.update(reporter);
|
||||
const topics = normalizeMode1Topics([fetcher._topic(1)]);
|
||||
const topics = normalizeMode1Topics([fetcher._topic(t1.topicId)]);
|
||||
expect(repo.topics()).toEqual(topics);
|
||||
expect(repo.posts()).toEqual([fetcher._post(pid)]);
|
||||
expect(repo.posts()).toEqual([fetcher._post(t1.postId)]);
|
||||
expect(repo.likes()).toEqual([]);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
"Warning: Encountered error 'FOREIGN KEY constraint failed' " +
|
||||
@ -481,21 +780,30 @@ describe("plugins/discourse/mirror", () => {
|
||||
spyWarn().mockReset();
|
||||
});
|
||||
|
||||
it("warns if a user's likes are missing", async () => {
|
||||
it("ignores if a user's likes are missing", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const pid = fetcher.addPost(1, null, "credbot");
|
||||
const t1 = fetcher.addTopic({authorUsername: "credbot"});
|
||||
(fetcher: any).likesByUser = async () => null;
|
||||
await mirror.update(reporter);
|
||||
expect(repo.topics()).toEqual(normalizeMode1Topics([fetcher._topic(1)]));
|
||||
expect(repo.posts()).toEqual([fetcher._post(pid)]);
|
||||
expect(repo.topics()).toEqual(
|
||||
normalizeMode1Topics([fetcher._topic(t1.topicId)])
|
||||
);
|
||||
expect(repo.posts()).toEqual([fetcher._post(t1.postId)]);
|
||||
expect(repo.likes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("inserts other likes if one user's likes are missing", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const p1 = fetcher.addPost(1, null, "credbot");
|
||||
const p2 = fetcher.addPost(1, 1, "otheruser");
|
||||
const l1 = fetcher.addLike("otheruser", 1, 123);
|
||||
const t1 = fetcher.addTopic({authorUsername: "credbot"});
|
||||
const p2 = fetcher.addPost({
|
||||
topicId: t1.topicId,
|
||||
authorUsername: "otheruser",
|
||||
});
|
||||
const l1 = fetcher.addLike({
|
||||
username: "otheruser",
|
||||
postId: t1.postId,
|
||||
timestampMs: 123,
|
||||
});
|
||||
const _likesByUser = fetcher.likesByUser.bind(fetcher);
|
||||
(fetcher: any).likesByUser = async (
|
||||
targetUsername: string,
|
||||
@ -506,13 +814,16 @@ describe("plugins/discourse/mirror", () => {
|
||||
};
|
||||
await mirror.update(reporter);
|
||||
expect(repo.topics()).toEqual(normalizeMode1Topics([fetcher._topic(1)]));
|
||||
expect(repo.posts()).toEqual([fetcher._post(p1), fetcher._post(p2)]);
|
||||
expect(repo.posts()).toEqual([
|
||||
fetcher._post(t1.postId),
|
||||
fetcher._post(p2),
|
||||
]);
|
||||
expect(repo.likes()).toEqual([l1]);
|
||||
});
|
||||
|
||||
it("sends the right tasks to the TaskReporter", async () => {
|
||||
const {mirror, fetcher, reporter} = example();
|
||||
fetcher.addPost(1, null, "credbot");
|
||||
fetcher.addTopic({authorUsername: "credbot"});
|
||||
await mirror.update(reporter);
|
||||
expect(reporter.activeTasks()).toEqual([]);
|
||||
expect(reporter.entries()).toEqual([
|
||||
@ -530,36 +841,36 @@ describe("plugins/discourse/mirror", () => {
|
||||
describe("findPostInTopic", () => {
|
||||
it("works for the first post in a topic", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
const id = fetcher.addPost(5, null);
|
||||
const post = NullUtil.get(fetcher._post(id));
|
||||
expect(post.topicId).toEqual(5);
|
||||
const t1 = fetcher.addTopic();
|
||||
const post = NullUtil.get(fetcher._post(t1.postId));
|
||||
expect(post.topicId).toEqual(t1.topicId);
|
||||
expect(post.indexWithinTopic).toEqual(1);
|
||||
await mirror.update(reporter);
|
||||
expect(repo.findPostInTopic(5, 1)).toEqual(id);
|
||||
expect(repo.findPostInTopic(t1.topicId, 1)).toEqual(t1.postId);
|
||||
});
|
||||
|
||||
it("works for the second post in a topic", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
const id = fetcher.addPost(1, 1);
|
||||
const post = NullUtil.get(fetcher._post(id));
|
||||
const t1 = fetcher.addTopic();
|
||||
const p2 = fetcher.addPost({topicId: t1.topicId});
|
||||
const post = NullUtil.get(fetcher._post(p2));
|
||||
expect(post.indexWithinTopic).toEqual(2);
|
||||
await mirror.update(reporter);
|
||||
expect(repo.findPostInTopic(1, 2)).toEqual(id);
|
||||
expect(repo.findPostInTopic(t1.topicId, 2)).toEqual(p2);
|
||||
});
|
||||
|
||||
it("returns undefined for a post with too high an index", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
const t1 = fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
expect(repo.findPostInTopic(1, 2)).toBe(undefined);
|
||||
expect(repo.findPostInTopic(t1.topicId, 2)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for topic that doesnt exist", async () => {
|
||||
const {mirror, fetcher, reporter, repo} = example();
|
||||
fetcher.addPost(1, null);
|
||||
const t1 = fetcher.addTopic();
|
||||
await mirror.update(reporter);
|
||||
expect(repo.findPostInTopic(2, 1)).toBe(undefined);
|
||||
expect(repo.findPostInTopic(t1.topicId + 1, 1)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for a mirror that never updated", async () => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user