mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-07-06 20:16:57 +00:00
implement replies collection for posts
This commit is contained in:
parent
62d35a56c1
commit
5182f17d32
4 changed files with 295 additions and 3 deletions
|
@ -29,10 +29,11 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { appendContentWarning } from '@/misc/append-content-warning.js';
|
import { appendContentWarning } from '@/misc/append-content-warning.js';
|
||||||
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { JsonLdService } from './JsonLdService.js';
|
import { JsonLdService } from './JsonLdService.js';
|
||||||
import { ApMfmService } from './ApMfmService.js';
|
import { ApMfmService } from './ApMfmService.js';
|
||||||
import { CONTEXT } from './misc/contexts.js';
|
import { CONTEXT } from './misc/contexts.js';
|
||||||
import { getApId } from './type.js';
|
import { getApId, IOrderedCollection, IOrderedCollectionPage } from './type.js';
|
||||||
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
|
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -70,6 +71,7 @@ export class ApRendererService {
|
||||||
private apMfmService: ApMfmService,
|
private apMfmService: ApMfmService,
|
||||||
private mfmService: MfmService,
|
private mfmService: MfmService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
|
private readonly queryService: QueryService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -388,13 +390,16 @@ export class ApRendererService {
|
||||||
|
|
||||||
let to: string[] = [];
|
let to: string[] = [];
|
||||||
let cc: string[] = [];
|
let cc: string[] = [];
|
||||||
|
let isPublic = false;
|
||||||
|
|
||||||
if (note.visibility === 'public') {
|
if (note.visibility === 'public') {
|
||||||
to = ['https://www.w3.org/ns/activitystreams#Public'];
|
to = ['https://www.w3.org/ns/activitystreams#Public'];
|
||||||
cc = [`${attributedTo}/followers`].concat(mentions);
|
cc = [`${attributedTo}/followers`].concat(mentions);
|
||||||
|
isPublic = true;
|
||||||
} else if (note.visibility === 'home') {
|
} else if (note.visibility === 'home') {
|
||||||
to = [`${attributedTo}/followers`];
|
to = [`${attributedTo}/followers`];
|
||||||
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
|
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
|
||||||
|
isPublic = true;
|
||||||
} else if (note.visibility === 'followers') {
|
} else if (note.visibility === 'followers') {
|
||||||
to = [`${attributedTo}/followers`];
|
to = [`${attributedTo}/followers`];
|
||||||
cc = mentions;
|
cc = mentions;
|
||||||
|
@ -455,6 +460,10 @@ export class ApRendererService {
|
||||||
})),
|
})),
|
||||||
} as const : {};
|
} as const : {};
|
||||||
|
|
||||||
|
// Render the outer replies collection wrapper, which contains the count but not the actual URLs.
|
||||||
|
// This saves one hop (request) when de-referencing the replies.
|
||||||
|
const replies = isPublic ? await this.renderRepliesCollection(note.id) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${this.config.url}/notes/${note.id}`,
|
id: `${this.config.url}/notes/${note.id}`,
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
|
@ -473,6 +482,7 @@ export class ApRendererService {
|
||||||
to,
|
to,
|
||||||
cc,
|
cc,
|
||||||
inReplyTo,
|
inReplyTo,
|
||||||
|
replies,
|
||||||
attachment: files.map(x => this.renderDocument(x)),
|
attachment: files.map(x => this.renderDocument(x)),
|
||||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
||||||
tag,
|
tag,
|
||||||
|
@ -909,6 +919,67 @@ export class ApRendererService {
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the reply collection wrapper object for a note
|
||||||
|
* @param noteId Note whose reply collection to render.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async renderRepliesCollection(noteId: string): Promise<IOrderedCollection> {
|
||||||
|
const replyCount = await this.notesRepository.countBy({
|
||||||
|
replyId: noteId,
|
||||||
|
visibility: In(['public', 'home']),
|
||||||
|
localOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'OrderedCollection',
|
||||||
|
id: `${this.config.url}/notes/${noteId}/replies`,
|
||||||
|
first: `${this.config.url}/notes/${noteId}/replies?page=true`,
|
||||||
|
totalItems: replyCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a page of the replies collection for a note
|
||||||
|
* @param noteId Return notes that are inReplyTo this value.
|
||||||
|
* @param untilId If set, return only notes that are *older* than this value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async renderRepliesCollectionPage(noteId: string, untilId: string | undefined): Promise<IOrderedCollectionPage> {
|
||||||
|
const replyCount = await this.notesRepository.countBy({
|
||||||
|
replyId: noteId,
|
||||||
|
visibility: In(['public', 'home']),
|
||||||
|
localOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const limit = 50;
|
||||||
|
const results = await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), undefined, untilId)
|
||||||
|
.andWhere({
|
||||||
|
replyId: noteId,
|
||||||
|
visibility: In(['public', 'home']),
|
||||||
|
localOnly: false,
|
||||||
|
})
|
||||||
|
.select(['note.id', 'note.uri'])
|
||||||
|
.limit(limit)
|
||||||
|
.getRawMany<{ note_id: string, note_uri: string | null }>();
|
||||||
|
|
||||||
|
const hasNextPage = results.length >= limit;
|
||||||
|
const baseId = `${this.config.url}/notes/${noteId}/replies?page=true`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'OrderedCollectionPage',
|
||||||
|
id: untilId == null ? baseId : `${baseId}&until_id=${untilId}`,
|
||||||
|
partOf: `${this.config.url}/notes/${noteId}/replies`,
|
||||||
|
first: baseId,
|
||||||
|
next: hasNextPage ? `${baseId}&until_id=${results.at(-1)?.note_id}` : undefined,
|
||||||
|
totalItems: replyCount,
|
||||||
|
orderedItems: results.map(r => {
|
||||||
|
// Remote notes have a URI, local have just an ID.
|
||||||
|
return r.note_uri ?? `${this.config.url}/notes/${r.note_id}`;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
|
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
|
||||||
if (names.length === 0) return [];
|
if (names.length === 0) return [];
|
||||||
|
|
|
@ -26,7 +26,7 @@ export interface IObject {
|
||||||
attributedTo?: ApObject;
|
attributedTo?: ApObject;
|
||||||
attachment?: any[];
|
attachment?: any[];
|
||||||
inReplyTo?: any;
|
inReplyTo?: any;
|
||||||
replies?: ICollection;
|
replies?: ICollection | IOrderedCollection | string;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
startTime?: Date;
|
startTime?: Date;
|
||||||
endTime?: Date;
|
endTime?: Date;
|
||||||
|
@ -125,6 +125,8 @@ export interface ICollection extends IObject {
|
||||||
type: 'Collection';
|
type: 'Collection';
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
first?: IObject | string;
|
first?: IObject | string;
|
||||||
|
last?: IObject | string;
|
||||||
|
current?: IObject | string;
|
||||||
items?: ApObject;
|
items?: ApObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,6 +134,32 @@ export interface IOrderedCollection extends IObject {
|
||||||
type: 'OrderedCollection';
|
type: 'OrderedCollection';
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
first?: IObject | string;
|
first?: IObject | string;
|
||||||
|
last?: IObject | string;
|
||||||
|
current?: IObject | string;
|
||||||
|
orderedItems?: ApObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICollectionPage extends IObject {
|
||||||
|
type: 'CollectionPage';
|
||||||
|
totalItems: number;
|
||||||
|
first?: IObject | string;
|
||||||
|
last?: IObject | string;
|
||||||
|
current?: IObject | string;
|
||||||
|
partOf?: IObject | string;
|
||||||
|
next?: IObject | string;
|
||||||
|
prev?: IObject | string;
|
||||||
|
items?: ApObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOrderedCollectionPage extends IObject {
|
||||||
|
type: 'OrderedCollectionPage';
|
||||||
|
totalItems: number;
|
||||||
|
first?: IObject | string;
|
||||||
|
last?: IObject | string;
|
||||||
|
current?: IObject | string;
|
||||||
|
partOf?: IObject | string;
|
||||||
|
next?: IObject | string;
|
||||||
|
prev?: IObject | string;
|
||||||
orderedItems?: ApObject;
|
orderedItems?: ApObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,8 +259,14 @@ export const isCollection = (object: IObject): object is ICollection =>
|
||||||
export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
|
export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
|
||||||
getApType(object) === 'OrderedCollection';
|
getApType(object) === 'OrderedCollection';
|
||||||
|
|
||||||
|
export const isCollectionPage = (object: IObject): object is ICollectionPage =>
|
||||||
|
getApType(object) === 'CollectionPage';
|
||||||
|
|
||||||
|
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
|
||||||
|
getApType(object) === 'OrderedCollectionPage';
|
||||||
|
|
||||||
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
|
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
|
||||||
isCollection(object) || isOrderedCollection(object);
|
isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object);
|
||||||
|
|
||||||
export interface IApPropertyValue extends IObject {
|
export interface IApPropertyValue extends IObject {
|
||||||
type: 'PropertyValue';
|
type: 'PropertyValue';
|
||||||
|
|
|
@ -783,6 +783,52 @@ export class ActivityPubServerService {
|
||||||
return (this.apRendererService.addContext(await this.packActivity(note, author)));
|
return (this.apRendererService.addContext(await this.packActivity(note, author)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// replies
|
||||||
|
fastify.get<{
|
||||||
|
Params: { note: string; };
|
||||||
|
Querystring: { page?: unknown; until_id?: unknown; };
|
||||||
|
}>('/notes/:note/replies', async (request, reply) => {
|
||||||
|
vary(reply.raw, 'Accept');
|
||||||
|
this.setResponseType(request, reply);
|
||||||
|
|
||||||
|
// Raw query to avoid fetching the while entity just to check access and get the user ID
|
||||||
|
const note = await this.notesRepository
|
||||||
|
.createQueryBuilder('note')
|
||||||
|
.andWhere({
|
||||||
|
id: request.params.note,
|
||||||
|
userHost: IsNull(),
|
||||||
|
visibility: In(['public', 'home']),
|
||||||
|
localOnly: false,
|
||||||
|
})
|
||||||
|
.select(['note.id', 'note.userId'])
|
||||||
|
.getRawOne<{ note_id: string, note_userId: string }>();
|
||||||
|
|
||||||
|
const { reject } = await this.checkAuthorizedFetch(request, reply, note?.note_userId);
|
||||||
|
if (reject) return;
|
||||||
|
|
||||||
|
if (note == null) {
|
||||||
|
reply.code(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const untilId = request.query.until_id;
|
||||||
|
if (untilId != null && typeof(untilId) !== 'string') {
|
||||||
|
reply.code(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If page is unset, then we just provide the outer wrapper.
|
||||||
|
// This is because the spec doesn't allow the wrapper to contain both elements *and* pages.
|
||||||
|
// We could technically do it anyway, but that may break other instances.
|
||||||
|
if (request.query.page !== 'true') {
|
||||||
|
const collection = await this.apRendererService.renderRepliesCollection(note.note_id);
|
||||||
|
return this.apRendererService.addContext(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await this.apRendererService.renderRepliesCollectionPage(note.note_id, untilId ?? undefined);
|
||||||
|
return this.apRendererService.addContext(page);
|
||||||
|
});
|
||||||
|
|
||||||
// outbox
|
// outbox
|
||||||
fastify.get<{
|
fastify.get<{
|
||||||
Params: { user: string; };
|
Params: { user: string; };
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { generateKeyPair } from 'crypto';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { jest } from '@jest/globals';
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
|
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
|
||||||
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
|
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
|
||||||
|
@ -99,6 +100,7 @@ describe('ActivityPub', () => {
|
||||||
let idService: IdService;
|
let idService: IdService;
|
||||||
let userPublickeysRepository: UserPublickeysRepository;
|
let userPublickeysRepository: UserPublickeysRepository;
|
||||||
let userKeypairService: UserKeypairService;
|
let userKeypairService: UserKeypairService;
|
||||||
|
let config: Config;
|
||||||
|
|
||||||
const metaInitial = {
|
const metaInitial = {
|
||||||
cacheRemoteFiles: true,
|
cacheRemoteFiles: true,
|
||||||
|
@ -149,6 +151,7 @@ describe('ActivityPub', () => {
|
||||||
idService = app.get<IdService>(IdService);
|
idService = app.get<IdService>(IdService);
|
||||||
userPublickeysRepository = app.get<UserPublickeysRepository>(DI.userPublickeysRepository);
|
userPublickeysRepository = app.get<UserPublickeysRepository>(DI.userPublickeysRepository);
|
||||||
userKeypairService = app.get<UserKeypairService>(UserKeypairService);
|
userKeypairService = app.get<UserKeypairService>(UserKeypairService);
|
||||||
|
config = app.get<Config>(DI.config);
|
||||||
|
|
||||||
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
|
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
|
||||||
const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService);
|
const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService);
|
||||||
|
@ -612,6 +615,40 @@ describe('ActivityPub', () => {
|
||||||
expect(result.summary).toBe('original and mandatory');
|
expect(result.summary).toBe('original and mandatory');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('replies', () => {
|
||||||
|
it('should be included when visibility=public', async () => {
|
||||||
|
note.visibility = 'public';
|
||||||
|
|
||||||
|
const rendered = await rendererService.renderNote(note, author, false);
|
||||||
|
|
||||||
|
expect(rendered.replies).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be included when visibility=home', async () => {
|
||||||
|
note.visibility = 'home';
|
||||||
|
|
||||||
|
const rendered = await rendererService.renderNote(note, author, false);
|
||||||
|
|
||||||
|
expect(rendered.replies).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be excluded when visibility=followers', async () => {
|
||||||
|
note.visibility = 'followers';
|
||||||
|
|
||||||
|
const rendered = await rendererService.renderNote(note, author, false);
|
||||||
|
|
||||||
|
expect(rendered.replies).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be excluded when visibility=specified', async () => {
|
||||||
|
note.visibility = 'specified';
|
||||||
|
|
||||||
|
const rendered = await rendererService.renderNote(note, author, false);
|
||||||
|
|
||||||
|
expect(rendered.replies).not.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renderUpnote', () => {
|
describe('renderUpnote', () => {
|
||||||
|
@ -695,6 +732,110 @@ describe('ActivityPub', () => {
|
||||||
expect(result.name).toBeUndefined();
|
expect(result.name).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('renderRepliesCollection', () => {
|
||||||
|
it('should include type', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollection(note.id);
|
||||||
|
|
||||||
|
expect(collection.type).toBe('OrderedCollection');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include id', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollection(note.id);
|
||||||
|
|
||||||
|
expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include first', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollection(note.id);
|
||||||
|
|
||||||
|
expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include totalItems', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollection(note.id);
|
||||||
|
|
||||||
|
expect(collection.totalItems).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderRepliesCollectionPage', () => {
|
||||||
|
describe('with untilId', () => {
|
||||||
|
it('should include type', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
|
||||||
|
|
||||||
|
expect(collection.type).toBe('OrderedCollectionPage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include id', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
|
||||||
|
|
||||||
|
expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true&until_id=abc123`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include partOf', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
|
||||||
|
|
||||||
|
expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include first', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
|
||||||
|
|
||||||
|
expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include totalItems', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
|
||||||
|
|
||||||
|
expect(collection.totalItems).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include orderedItems', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
|
||||||
|
|
||||||
|
expect(collection.orderedItems).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('without untilId', () => {
|
||||||
|
it('should include type', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
|
||||||
|
|
||||||
|
expect(collection.type).toBe('OrderedCollectionPage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include id', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
|
||||||
|
|
||||||
|
expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include partOf', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
|
||||||
|
|
||||||
|
expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include first', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
|
||||||
|
|
||||||
|
expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include totalItems', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
|
||||||
|
|
||||||
|
expect(collection.totalItems).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include orderedItems', async () => {
|
||||||
|
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
|
||||||
|
|
||||||
|
expect(collection.orderedItems).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(ApPersonService, () => {
|
describe(ApPersonService, () => {
|
||||||
|
|
Loading…
Add table
Reference in a new issue