summaryrefslogtreecommitdiff
path: root/packages/backend/test
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/test')
-rw-r--r--packages/backend/test/e2e/oauth.ts2
-rw-r--r--packages/backend/test/misc/FakeInternalEventService.ts92
-rw-r--r--packages/backend/test/misc/mock-resolver.ts2
-rw-r--r--packages/backend/test/misc/noOpCaches.ts187
-rw-r--r--packages/backend/test/tsconfig.json1
-rw-r--r--packages/backend/test/unit/AbuseReportNotificationService.ts74
-rw-r--r--packages/backend/test/unit/AnnouncementService.ts26
-rw-r--r--packages/backend/test/unit/MetaService.ts11
-rw-r--r--packages/backend/test/unit/MfmService.ts4
-rw-r--r--packages/backend/test/unit/NoteCreateService.ts3
-rw-r--r--packages/backend/test/unit/RoleService.ts32
-rw-r--r--packages/backend/test/unit/UserSearchService.ts18
-rw-r--r--packages/backend/test/unit/activitypub.ts86
-rw-r--r--packages/backend/test/unit/entities/UserEntityService.ts9
-rw-r--r--packages/backend/test/unit/extract-mentions.ts2
-rw-r--r--packages/backend/test/unit/misc/QuantumKVCache.ts799
-rw-r--r--packages/backend/test/unit/misc/diff-arrays.ts91
-rw-r--r--packages/backend/test/unit/misc/is-renote.ts3
-rw-r--r--packages/backend/test/unit/misc/is-retryable-error.ts75
-rw-r--r--packages/backend/test/utils.ts6
20 files changed, 1437 insertions, 86 deletions
diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts
index 47851e9474..1dc8d87593 100644
--- a/packages/backend/test/e2e/oauth.ts
+++ b/packages/backend/test/e2e/oauth.ts
@@ -19,7 +19,7 @@ import {
ResourceOwnerPassword,
} from 'simple-oauth2';
import pkceChallenge from 'pkce-challenge';
-import { load as cheerio } from 'cheerio';
+import { load as cheerio } from 'cheerio/slim';
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
import { api, port, sendEnvUpdateRequest, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
diff --git a/packages/backend/test/misc/FakeInternalEventService.ts b/packages/backend/test/misc/FakeInternalEventService.ts
new file mode 100644
index 0000000000..d18a080eaf
--- /dev/null
+++ b/packages/backend/test/misc/FakeInternalEventService.ts
@@ -0,0 +1,92 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { Listener, ListenerProps } from '@/core/InternalEventService.js';
+import type Redis from 'ioredis';
+import type { GlobalEventService, InternalEventTypes } from '@/core/GlobalEventService.js';
+import { InternalEventService } from '@/core/InternalEventService.js';
+import { bindThis } from '@/decorators.js';
+
+type FakeCall<K extends keyof InternalEventService> = [K, Parameters<InternalEventService[K]>];
+type FakeListener<K extends keyof InternalEventTypes> = [K, Listener<K>, ListenerProps];
+
+/**
+ * Minimal implementation of InternalEventService meant for use in unit tests.
+ * There is no redis connection, and metadata is tracked in the public _calls and _listeners arrays.
+ * The on/off/emit methods are fully functional and can be called in tests to invoke any registered listeners.
+ */
+export class FakeInternalEventService extends InternalEventService {
+ /**
+ * List of calls to public methods, in chronological order.
+ */
+ public _calls: FakeCall<keyof InternalEventService>[] = [];
+
+ /**
+ * List of currently registered listeners.
+ */
+ public _listeners: FakeListener<keyof InternalEventTypes>[] = [];
+
+ /**
+ * Resets the mock.
+ * Clears all listeners and tracked calls.
+ */
+ public _reset() {
+ this._calls = [];
+ this._listeners = [];
+ }
+
+ /**
+ * Simulates a remote event sent from another process in the cluster via redis.
+ */
+ @bindThis
+ public async _emitRedis<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K]): Promise<void> {
+ await this.emit(type, value, false);
+ }
+
+ constructor() {
+ super(
+ { on: () => {} } as unknown as Redis.Redis,
+ {} as unknown as GlobalEventService,
+ );
+ }
+
+ @bindThis
+ public on<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>, props?: ListenerProps): void {
+ if (!this._listeners.some(l => l[0] === type && l[1] === listener)) {
+ this._listeners.push([type, listener as Listener<keyof InternalEventTypes>, props ?? {}]);
+ }
+ this._calls.push(['on', [type, listener as Listener<keyof InternalEventTypes>, props]]);
+ }
+
+ @bindThis
+ public off<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>): void {
+ this._listeners = this._listeners.filter(l => l[0] !== type || l[1] !== listener);
+ this._calls.push(['off', [type, listener as Listener<keyof InternalEventTypes>]]);
+ }
+
+ @bindThis
+ public async emit<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K], isLocal = true): Promise<void> {
+ for (const listener of this._listeners) {
+ if (listener[0] === type) {
+ if ((isLocal && !listener[2].ignoreLocal) || (!isLocal && !listener[2].ignoreRemote)) {
+ await listener[1](value, type, isLocal);
+ }
+ }
+ }
+ this._calls.push(['emit', [type, value]]);
+ }
+
+ @bindThis
+ public dispose(): void {
+ this._listeners = [];
+ this._calls.push(['dispose', []]);
+ }
+
+ @bindThis
+ public onApplicationShutdown(): void {
+ this._calls.push(['onApplicationShutdown', []]);
+ }
+}
+
diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts
index 0bf85ef8eb..34241d13cb 100644
--- a/packages/backend/test/misc/mock-resolver.ts
+++ b/packages/backend/test/misc/mock-resolver.ts
@@ -19,6 +19,7 @@ import type {
PollsRepository,
UsersRepository,
} from '@/models/_.js';
+import type { CacheService } from '@/core/CacheService.js';
import { ApLogService } from '@/core/ApLogService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { fromTuple } from '@/misc/from-tuple.js';
@@ -53,6 +54,7 @@ export class MockResolver extends Resolver {
loggerService,
{} as ApLogService,
{} as ApUtilityService,
+ {} as CacheService,
);
}
diff --git a/packages/backend/test/misc/noOpCaches.ts b/packages/backend/test/misc/noOpCaches.ts
new file mode 100644
index 0000000000..f3cc1e2ba2
--- /dev/null
+++ b/packages/backend/test/misc/noOpCaches.ts
@@ -0,0 +1,187 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Redis from 'ioredis';
+import { Inject } from '@nestjs/common';
+import { FakeInternalEventService } from './FakeInternalEventService.js';
+import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { MiLocalUser } from '@/models/User.js';
+import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js';
+import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js';
+import { CacheService, FollowStats } from '@/core/CacheService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { InternalEventService } from '@/core/InternalEventService.js';
+
+export function noOpRedis() {
+ return {
+ set: () => Promise.resolve(),
+ get: () => Promise.resolve(null),
+ del: () => Promise.resolve(),
+ on: () => {},
+ off: () => {},
+ } as unknown as Redis.Redis;
+}
+
+export class NoOpCacheService extends CacheService {
+ public readonly fakeRedis: {
+ [K in keyof Redis.Redis]: Redis.Redis[K];
+ };
+ public readonly fakeInternalEventService: FakeInternalEventService;
+
+ constructor(
+ @Inject(DI.usersRepository)
+ usersRepository: UsersRepository,
+
+ @Inject(DI.userProfilesRepository)
+ userProfilesRepository: UserProfilesRepository,
+
+ @Inject(DI.mutingsRepository)
+ mutingsRepository: MutingsRepository,
+
+ @Inject(DI.blockingsRepository)
+ blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.renoteMutingsRepository)
+ renoteMutingsRepository: RenoteMutingsRepository,
+
+ @Inject(DI.followingsRepository)
+ followingsRepository: FollowingsRepository,
+
+ @Inject(UserEntityService)
+ userEntityService: UserEntityService,
+ ) {
+ const fakeRedis = noOpRedis();
+ const fakeInternalEventService = new FakeInternalEventService();
+
+ super(
+ fakeRedis,
+ fakeRedis,
+ usersRepository,
+ userProfilesRepository,
+ mutingsRepository,
+ blockingsRepository,
+ renoteMutingsRepository,
+ followingsRepository,
+ userEntityService,
+ fakeInternalEventService,
+ );
+
+ this.fakeRedis = fakeRedis;
+ this.fakeInternalEventService = fakeInternalEventService;
+
+ // Override caches
+ this.userByIdCache = new NoOpMemoryKVCache<MiUser>();
+ this.localUserByNativeTokenCache = new NoOpMemoryKVCache<MiLocalUser | null>();
+ this.localUserByIdCache = new NoOpMemoryKVCache<MiLocalUser>();
+ this.uriPersonCache = new NoOpMemoryKVCache<MiUser | null>();
+ this.userProfileCache = NoOpQuantumKVCache.copy(this.userProfileCache, fakeInternalEventService);
+ this.userMutingsCache = NoOpQuantumKVCache.copy(this.userMutingsCache, fakeInternalEventService);
+ this.userBlockingCache = NoOpQuantumKVCache.copy(this.userBlockingCache, fakeInternalEventService);
+ this.userBlockedCache = NoOpQuantumKVCache.copy(this.userBlockedCache, fakeInternalEventService);
+ this.renoteMutingsCache = NoOpQuantumKVCache.copy(this.renoteMutingsCache, fakeInternalEventService);
+ this.userFollowingsCache = NoOpQuantumKVCache.copy(this.userFollowingsCache, fakeInternalEventService);
+ this.userFollowersCache = NoOpQuantumKVCache.copy(this.userFollowersCache, fakeInternalEventService);
+ this.hibernatedUserCache = NoOpQuantumKVCache.copy(this.hibernatedUserCache, fakeInternalEventService);
+ this.userFollowStatsCache = new NoOpMemoryKVCache<FollowStats>();
+ this.translationsCache = NoOpRedisKVCache.copy(this.translationsCache, fakeRedis);
+ }
+}
+
+export class NoOpMemoryKVCache<T> extends MemoryKVCache<T> {
+ constructor() {
+ super(-1);
+ }
+}
+
+export class NoOpMemorySingleCache<T> extends MemorySingleCache<T> {
+ constructor() {
+ super(-1);
+ }
+}
+
+export class NoOpRedisKVCache<T> extends RedisKVCache<T> {
+ constructor(opts?: {
+ redis?: Redis.Redis;
+ fetcher?: RedisKVCache<T>['fetcher'];
+ toRedisConverter?: RedisKVCache<T>['toRedisConverter'];
+ fromRedisConverter?: RedisKVCache<T>['fromRedisConverter'];
+ }) {
+ super(
+ opts?.redis ?? noOpRedis(),
+ 'no-op',
+ {
+ lifetime: -1,
+ memoryCacheLifetime: -1,
+ fetcher: opts?.fetcher,
+ toRedisConverter: opts?.toRedisConverter,
+ fromRedisConverter: opts?.fromRedisConverter,
+ },
+ );
+ }
+
+ public static copy<T>(cache: RedisKVCache<T>, redis?: Redis.Redis): NoOpRedisKVCache<T> {
+ return new NoOpRedisKVCache<T>({
+ redis,
+ fetcher: cache.fetcher,
+ toRedisConverter: cache.toRedisConverter,
+ fromRedisConverter: cache.fromRedisConverter,
+ });
+ }
+}
+
+export class NoOpRedisSingleCache<T> extends RedisSingleCache<T> {
+ constructor(opts?: {
+ redis?: Redis.Redis;
+ fetcher?: RedisSingleCache<T>['fetcher'];
+ toRedisConverter?: RedisSingleCache<T>['toRedisConverter'];
+ fromRedisConverter?: RedisSingleCache<T>['fromRedisConverter'];
+ }) {
+ super(
+ opts?.redis ?? noOpRedis(),
+ 'no-op',
+ {
+ lifetime: -1,
+ memoryCacheLifetime: -1,
+ fetcher: opts?.fetcher,
+ toRedisConverter: opts?.toRedisConverter,
+ fromRedisConverter: opts?.fromRedisConverter,
+ },
+ );
+ }
+
+ public static copy<T>(cache: RedisSingleCache<T>, redis?: Redis.Redis): NoOpRedisSingleCache<T> {
+ return new NoOpRedisSingleCache<T>({
+ redis,
+ fetcher: cache.fetcher,
+ toRedisConverter: cache.toRedisConverter,
+ fromRedisConverter: cache.fromRedisConverter,
+ });
+ }
+}
+
+export class NoOpQuantumKVCache<T> extends QuantumKVCache<T> {
+ constructor(opts: Omit<QuantumKVOpts<T>, 'lifetime'> & {
+ internalEventService?: InternalEventService,
+ }) {
+ super(
+ opts.internalEventService ?? new FakeInternalEventService(),
+ 'no-op',
+ {
+ ...opts,
+ lifetime: -1,
+ },
+ );
+ }
+
+ public static copy<T>(cache: QuantumKVCache<T>, internalEventService?: InternalEventService): NoOpQuantumKVCache<T> {
+ return new NoOpQuantumKVCache<T>({
+ internalEventService,
+ fetcher: cache.fetcher,
+ bulkFetcher: cache.bulkFetcher,
+ onChanged: cache.onChanged,
+ });
+ }
+}
diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json
index 2b562acda8..f3b6a5108d 100644
--- a/packages/backend/test/tsconfig.json
+++ b/packages/backend/test/tsconfig.json
@@ -23,6 +23,7 @@
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"isolatedModules": true,
+ "incremental": true,
"baseUrl": "./",
"paths": {
"@/*": ["../src/*"]
diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts
index 6d555326fb..a67cb3664a 100644
--- a/packages/backend/test/unit/AbuseReportNotificationService.ts
+++ b/packages/backend/test/unit/AbuseReportNotificationService.ts
@@ -11,6 +11,7 @@ import {
AbuseReportNotificationRecipientRepository,
MiAbuseReportNotificationRecipient,
MiAbuseUserReport,
+ MiMeta,
MiSystemWebhook,
MiUser,
SystemWebhooksRepository,
@@ -56,6 +57,15 @@ describe('AbuseReportNotificationService', () => {
// --------------------------------------------------------------------------------------
+ const meta = {} as MiMeta;
+
+ function updateMeta(newMeta: Partial<MiMeta>): void {
+ for (const key in meta) {
+ delete (meta as any)[key];
+ }
+ Object.assign(meta, newMeta);
+ }
+
async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
@@ -66,6 +76,8 @@ describe('AbuseReportNotificationService', () => {
await userProfilesRepository.insert({
userId: user.id,
+ email: user.username + '@example.com',
+ emailVerified: true,
});
return user;
@@ -130,6 +142,9 @@ describe('AbuseReportNotificationService', () => {
{
provide: GlobalEventService, useFactory: () => ({ publishAdminStream: jest.fn() }),
},
+ {
+ provide: DI.meta, useFactory: () => meta,
+ },
],
})
.compile();
@@ -156,6 +171,8 @@ describe('AbuseReportNotificationService', () => {
systemWebhook2 = await createWebhook();
roleService.getModeratorIds.mockResolvedValue([root.id, alice.id, bob.id]);
+
+ updateMeta({} as MiMeta);
});
afterEach(async () => {
@@ -367,8 +384,10 @@ describe('AbuseReportNotificationService', () => {
id: idService.gen(),
targetUserId: alice.id,
targetUser: alice,
+ targetUserInstance: null,
reporterId: bob.id,
reporter: bob,
+ reporterInstance: null,
assigneeId: null,
assignee: null,
resolved: false,
@@ -390,4 +409,59 @@ describe('AbuseReportNotificationService', () => {
expect(webhookService.enqueueSystemWebhook.mock.calls[0][2]).toEqual({ excludes: [systemWebhook2.id] });
});
});
+
+ describe('collection of recipient-mails', () => {
+ async function create() {
+ const recipient = await createRecipient({
+ method: 'email',
+ userId: alice.id,
+ });
+
+ return recipient;
+ }
+
+ test('with nothing set', async () => {
+ const mails = await service.getRecipientEMailAddresses();
+ expect(mails).toEqual([]);
+ });
+
+ test('with maintainer mail set', async () => {
+ updateMeta({ maintainerEmail: 'maintainer_mail' });
+ const mails = await service.getRecipientEMailAddresses();
+ expect(mails).toEqual(['maintainer_mail']);
+ });
+
+ test('with smtp mail set', async () => {
+ updateMeta({ email: 'smtp_mail' });
+ const mails = await service.getRecipientEMailAddresses();
+ expect(mails).toEqual(['smtp_mail']);
+ });
+
+ test('with maintainer mail and smtp mail set', async () => {
+ updateMeta({ email: 'smtp_mail', maintainerEmail: 'maintainer_mail' });
+ const mails = await service.getRecipientEMailAddresses();
+ expect(mails).toEqual(['smtp_mail', 'maintainer_mail']);
+ });
+
+ test('with recipients', async () => {
+ await create();
+
+ const mails = await service.getRecipientEMailAddresses();
+ expect(mails).toEqual([
+ 'alice@example.com',
+ ]);
+ });
+
+ test('with recipients and maintainer mail set and smtp mail set', async () => {
+ await create();
+ updateMeta({ maintainerEmail: 'maintainer_mail', email: 'smtp_mail' });
+
+ const mails = await service.getRecipientEMailAddresses();
+ expect(mails).toEqual([
+ 'alice@example.com',
+ 'smtp_mail',
+ 'maintainer_mail',
+ ]);
+ });
+ });
});
diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts
index a79655c9aa..32d7df05bf 100644
--- a/packages/backend/test/unit/AnnouncementService.ts
+++ b/packages/backend/test/unit/AnnouncementService.ts
@@ -8,9 +8,12 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
+import { NoOpCacheService } from '../misc/noOpCaches.js';
+import { FakeInternalEventService } from '../misc/FakeInternalEventService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
+import { InternalEventService } from '@/core/InternalEventService.js';
import type {
AnnouncementReadsRepository,
AnnouncementsRepository,
@@ -71,24 +74,27 @@ describe('AnnouncementService', () => {
AnnouncementEntityService,
CacheService,
IdService,
+ InternalEventService,
+ GlobalEventService,
+ ModerationLogService,
],
})
.useMocker((token) => {
- if (token === GlobalEventService) {
- return {
- publishMainStream: jest.fn(),
- publishBroadcastStream: jest.fn(),
- };
- } else if (token === ModerationLogService) {
- return {
- log: jest.fn(),
- };
- } else if (typeof token === 'function') {
+ if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock();
}
})
+ .overrideProvider(GlobalEventService).useValue({
+ publishMainStream: jest.fn(),
+ publishBroadcastStream: jest.fn(),
+ } as unknown as GlobalEventService)
+ .overrideProvider(ModerationLogService).useValue({
+ log: jest.fn(),
+ })
+ .overrideProvider(InternalEventService).useClass(FakeInternalEventService)
+ .overrideProvider(CacheService).useClass(NoOpCacheService)
.compile();
app.enableShutdownHooks();
diff --git a/packages/backend/test/unit/MetaService.ts b/packages/backend/test/unit/MetaService.ts
index 19c98eab3d..056838e180 100644
--- a/packages/backend/test/unit/MetaService.ts
+++ b/packages/backend/test/unit/MetaService.ts
@@ -11,6 +11,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { DI } from '@/di-symbols.js';
import { MetaService } from '@/core/MetaService.js';
import { CoreModule } from '@/core/CoreModule.js';
+import { MetasRepository } from '@/models/_.js';
import type { TestingModule } from '@nestjs/testing';
import type { DataSource } from 'typeorm';
@@ -39,8 +40,8 @@ describe('MetaService', () => {
});
test('fetch (cache)', async () => {
- const db = app.get<DataSource>(DI.db);
- const spy = jest.spyOn(db, 'transaction');
+ const metasRepository = app.get<MetasRepository>(DI.metasRepository);
+ const spy = jest.spyOn(metasRepository, 'createQueryBuilder');
const result = await metaService.fetch();
@@ -49,12 +50,12 @@ describe('MetaService', () => {
});
test('fetch (force)', async () => {
- const db = app.get<DataSource>(DI.db);
- const spy = jest.spyOn(db, 'transaction');
+ const metasRepository = app.get<MetasRepository>(DI.metasRepository);
+ const spy = jest.spyOn(metasRepository, 'createQueryBuilder');
const result = await metaService.fetch(true);
expect(result.id).toBe('x');
- expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalled();
});
});
diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts
index e54c006a4f..af1fc4e132 100644
--- a/packages/backend/test/unit/MfmService.ts
+++ b/packages/backend/test/unit/MfmService.ts
@@ -4,7 +4,7 @@
*/
import * as assert from 'assert';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import { Test } from '@nestjs/testing';
import { CoreModule } from '@/core/CoreModule.js';
@@ -86,7 +86,7 @@ describe('MfmService', () => {
test('ruby', async () => {
const input = '$[ruby $[group *some* text] ignore me]';
- const output = '<p><ruby><span><span>*some*</span><span> text</span></span><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>';
+ const output = '<p><ruby><span><span>*some*</span> text</span><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>';
assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output);
});
});
diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts
index f4ecfef34d..63e3795a84 100644
--- a/packages/backend/test/unit/NoteCreateService.ts
+++ b/packages/backend/test/unit/NoteCreateService.ts
@@ -57,10 +57,13 @@ describe('NoteCreateService', () => {
channelId: null,
channel: null,
userHost: null,
+ userInstance: null,
replyUserId: null,
replyUserHost: null,
+ replyUserInstance: null,
renoteUserId: null,
renoteUserHost: null,
+ renoteUserInstance: null,
processErrors: [],
};
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index 553ff0982a..2afe22618d 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -10,11 +10,15 @@ import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers';
+import { NoOpCacheService } from '../misc/noOpCaches.js';
+import { FakeInternalEventService } from '../misc/FakeInternalEventService.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js';
import {
+ InstancesRepository,
+ MetasRepository,
MiMeta,
MiRole,
MiRoleAssignment,
@@ -33,20 +37,36 @@ import { secureRndstr } from '@/misc/secure-rndstr.js';
import { NotificationService } from '@/core/NotificationService.js';
import { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { InternalEventService } from '@/core/InternalEventService.js';
const moduleMocker = new ModuleMocker(global);
describe('RoleService', () => {
let app: TestingModule;
let roleService: RoleService;
+ let instancesRepository: InstancesRepository;
let usersRepository: UsersRepository;
let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository;
let meta: jest.Mocked<MiMeta>;
+ let metasRepository: MetasRepository;
let notificationService: jest.Mocked<NotificationService>;
let clock: lolex.InstalledClock;
async function createUser(data: Partial<MiUser> = {}) {
+ if (data.host != null) {
+ await instancesRepository
+ .createQueryBuilder('instance')
+ .insert()
+ .values({
+ id: genAidx(Date.now()),
+ firstRetrievedAt: new Date(),
+ host: data.host,
+ })
+ .orIgnore()
+ .execute();
+ }
+
const un = secureRndstr(16);
const x = await usersRepository.insert({
id: genAidx(Date.now()),
@@ -128,26 +148,30 @@ describe('RoleService', () => {
provide: NotificationService.name,
useExisting: NotificationService,
},
+ MetaService,
+ InternalEventService,
],
})
.useMocker((token) => {
- if (token === MetaService) {
- return { fetch: jest.fn() };
- }
if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock();
}
})
+ .overrideProvider(MetaService).useValue({ fetch: jest.fn() })
+ .overrideProvider(InternalEventService).useClass(FakeInternalEventService)
+ .overrideProvider(CacheService).useClass(NoOpCacheService)
.compile();
app.enableShutdownHooks();
roleService = app.get<RoleService>(RoleService);
+ instancesRepository = app.get<InstancesRepository>(DI.instancesRepository);
usersRepository = app.get<UsersRepository>(DI.usersRepository);
rolesRepository = app.get<RolesRepository>(DI.rolesRepository);
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);
+ metasRepository = app.get<MetasRepository>(DI.metasRepository);
meta = app.get<MiMeta>(DI.meta) as jest.Mocked<MiMeta>;
notificationService = app.get<NotificationService>(NotificationService) as jest.Mocked<NotificationService>;
@@ -159,7 +183,7 @@ describe('RoleService', () => {
clock.uninstall();
await Promise.all([
- app.get(DI.metasRepository).delete({}),
+ metasRepository.delete({}),
usersRepository.delete({}),
rolesRepository.delete({}),
roleAssignmentsRepository.delete({}),
diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts
index 697425beb8..a6b331d1cb 100644
--- a/packages/backend/test/unit/UserSearchService.ts
+++ b/packages/backend/test/unit/UserSearchService.ts
@@ -7,16 +7,18 @@ import { Test, TestingModule } from '@nestjs/testing';
import { describe, jest, test } from '@jest/globals';
import { In } from 'typeorm';
import { UserSearchService } from '@/core/UserSearchService.js';
-import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { FollowingsRepository, InstancesRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { genAidx } from '@/misc/id/aidx.js';
describe('UserSearchService', () => {
let app: TestingModule;
let service: UserSearchService;
+ let instancesRepository: InstancesRepository;
let usersRepository: UsersRepository;
let followingsRepository: FollowingsRepository;
let idService: IdService;
@@ -35,6 +37,19 @@ describe('UserSearchService', () => {
let bobby: MiUser;
async function createUser(data: Partial<MiUser> = {}) {
+ if (data.host != null) {
+ await instancesRepository
+ .createQueryBuilder('instance')
+ .insert()
+ .values({
+ id: genAidx(Date.now()),
+ firstRetrievedAt: new Date(),
+ host: data.host,
+ })
+ .orIgnore()
+ .execute();
+ }
+
const user = await usersRepository
.insert({
id: idService.gen(),
@@ -104,6 +119,7 @@ describe('UserSearchService', () => {
await app.init();
+ instancesRepository = app.get<InstancesRepository>(DI.instancesRepository);
usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository);
followingsRepository = app.get(DI.followingsRepository);
diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts
index 6f6d4c4121..ff93e1be07 100644
--- a/packages/backend/test/unit/activitypub.ts
+++ b/packages/backend/test/unit/activitypub.ts
@@ -9,8 +9,12 @@ import { generateKeyPair } from 'crypto';
import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals';
+import { NoOpCacheService } from '../misc/noOpCaches.js';
+import { FakeInternalEventService } from '../misc/FakeInternalEventService.js';
import type { Config } from '@/config.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
+import { InternalEventService } from '@/core/InternalEventService.js';
+import { CacheService } from '@/core/CacheService.js';
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
@@ -30,7 +34,7 @@ import { genAidx } from '@/misc/id/aidx.js';
import { IdService } from '@/core/IdService.js';
import { MockResolver } from '../misc/mock-resolver.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
-import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
+import { MemoryKVCache } from '@/misc/cache.js';
const host = 'https://host1.test';
@@ -103,6 +107,25 @@ describe('ActivityPub', () => {
let config: Config;
const metaInitial = {
+ id: 'x',
+ name: 'Test Instance',
+ shortName: 'Test Instance',
+ description: 'Test Instance',
+ langs: [] as string[],
+ pinnedUsers: [] as string[],
+ hiddenTags: [] as string[],
+ prohibitedWordsForNameOfUser: [] as string[],
+ silencedHosts: [] as string[],
+ mediaSilencedHosts: [] as string[],
+ policies: {},
+ serverRules: [] as string[],
+ bannedEmailDomains: [] as string[],
+ preservedUsernames: [] as string[],
+ bubbleInstances: [] as string[],
+ trustedLinkUrlPatterns: [] as string[],
+ federation: 'all',
+ federationHosts: [] as string[],
+ allowUnsignedFetch: 'always',
cacheRemoteFiles: true,
cacheRemoteSensitiveFiles: true,
enableFanoutTimeline: true,
@@ -135,6 +158,8 @@ describe('ActivityPub', () => {
},
})
.overrideProvider(DI.meta).useFactory({ factory: () => meta })
+ .overrideProvider(CacheService).useClass(NoOpCacheService)
+ .overrideProvider(InternalEventService).useClass(FakeInternalEventService)
.compile();
await app.init();
@@ -454,8 +479,6 @@ describe('ActivityPub', () => {
describe('JSON-LD', () => {
test('Compaction', async () => {
- const jsonLd = jsonLdService.use();
-
const object = {
'@context': [
'https://www.w3.org/ns/activitystreams',
@@ -474,7 +497,7 @@ describe('ActivityPub', () => {
unknown: 'test test bar',
undefined: 'test test baz',
};
- const compacted = await jsonLd.compact(object);
+ const compacted = await jsonLdService.compact(object);
assert.deepStrictEqual(compacted, {
'@context': CONTEXT,
@@ -537,7 +560,7 @@ describe('ActivityPub', () => {
publicKey,
privateKey,
});
- ((userKeypairService as unknown as { cache: RedisKVCache<MiUserKeypair> }).cache as unknown as { memoryCache: MemoryKVCache<MiUserKeypair> }).memoryCache.set(author.id, keypair);
+ (userKeypairService as unknown as { cache: MemoryKVCache<MiUserKeypair> }).cache.set(author.id, keypair);
note = new MiNote({
id: idService.gen(),
@@ -651,59 +674,6 @@ describe('ActivityPub', () => {
});
});
- describe('renderUpnote', () => {
- describe('summary', () => {
- // I actually don't know why it does this, but the logic was already there so I've preserved it.
- it('should be zero-width space when CW is empty string', async () => {
- note.cw = '';
-
- const result = await rendererService.renderUpNote(note, author, false);
-
- expect(result.summary).toBe(String.fromCharCode(0x200B));
- });
-
- it('should be undefined when CW is null', async () => {
- const result = await rendererService.renderUpNote(note, author, false);
-
- expect(result.summary).toBeUndefined();
- });
-
- it('should be CW when present without mandatoryCW', async () => {
- note.cw = 'original';
-
- const result = await rendererService.renderUpNote(note, author, false);
-
- expect(result.summary).toBe('original');
- });
-
- it('should be mandatoryCW when present without CW', async () => {
- author.mandatoryCW = 'mandatory';
-
- const result = await rendererService.renderUpNote(note, author, false);
-
- expect(result.summary).toBe('mandatory');
- });
-
- it('should be merged when CW and mandatoryCW are both present', async () => {
- note.cw = 'original';
- author.mandatoryCW = 'mandatory';
-
- const result = await rendererService.renderUpNote(note, author, false);
-
- expect(result.summary).toBe('original, mandatory');
- });
-
- it('should be CW when CW includes mandatoryCW', async () => {
- note.cw = 'original and mandatory';
- author.mandatoryCW = 'mandatory';
-
- const result = await rendererService.renderUpNote(note, author, false);
-
- expect(result.summary).toBe('original and mandatory');
- });
- });
- });
-
describe('renderPersonRedacted', () => {
it('should include minimal properties', async () => {
const result = await rendererService.renderPersonRedacted(author);
diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts
index ce3f931bb0..4f45f3216d 100644
--- a/packages/backend/test/unit/entities/UserEntityService.ts
+++ b/packages/backend/test/unit/entities/UserEntityService.ts
@@ -4,6 +4,8 @@
*/
import { Test, TestingModule } from '@nestjs/testing';
+import { FakeInternalEventService } from '../../misc/FakeInternalEventService.js';
+import { NoOpCacheService } from '../../misc/noOpCaches.js';
import type { MiUser } from '@/models/User.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalModule } from '@/GlobalModule.js';
@@ -51,6 +53,7 @@ import { ReactionService } from '@/core/ReactionService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { ChatService } from '@/core/ChatService.js';
+import { InternalEventService } from '@/core/InternalEventService.js';
process.env.NODE_ENV = 'test';
@@ -174,6 +177,7 @@ describe('UserEntityService', () => {
ReactionsBufferingService,
NotificationService,
ChatService,
+ InternalEventService,
];
app = await Test.createTestingModule({
@@ -182,7 +186,10 @@ describe('UserEntityService', () => {
...services,
...services.map(x => ({ provide: x.name, useExisting: x })),
],
- }).compile();
+ })
+ .overrideProvider(InternalEventService).useClass(FakeInternalEventService)
+ .overrideProvider(CacheService).useClass(NoOpCacheService)
+ .compile();
await app.init();
app.enableShutdownHooks();
diff --git a/packages/backend/test/unit/extract-mentions.ts b/packages/backend/test/unit/extract-mentions.ts
index 2aad89d65b..3403387e30 100644
--- a/packages/backend/test/unit/extract-mentions.ts
+++ b/packages/backend/test/unit/extract-mentions.ts
@@ -5,7 +5,7 @@
import * as assert from 'assert';
-import { parse } from '@transfem-org/sfm-js';
+import { parse } from 'mfm-js';
import { extractMentions } from '@/misc/extract-mentions.js';
describe('Extract mentions', () => {
diff --git a/packages/backend/test/unit/misc/QuantumKVCache.ts b/packages/backend/test/unit/misc/QuantumKVCache.ts
new file mode 100644
index 0000000000..92792171be
--- /dev/null
+++ b/packages/backend/test/unit/misc/QuantumKVCache.ts
@@ -0,0 +1,799 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { jest } from '@jest/globals';
+import { FakeInternalEventService } from '../../misc/FakeInternalEventService.js';
+import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js';
+
+describe(QuantumKVCache, () => {
+ let fakeInternalEventService: FakeInternalEventService;
+ let madeCaches: { dispose: () => void }[];
+
+ function makeCache<T>(opts?: Partial<QuantumKVOpts<T>> & { name?: string }): QuantumKVCache<T> {
+ const _opts = {
+ name: 'test',
+ lifetime: Infinity,
+ fetcher: () => { throw new Error('not implemented'); },
+ } satisfies QuantumKVOpts<T> & { name: string };
+
+ if (opts) {
+ Object.assign(_opts, opts);
+ }
+
+ const cache = new QuantumKVCache<T>(fakeInternalEventService, _opts.name, _opts);
+ madeCaches.push(cache);
+ return cache;
+ }
+
+ beforeEach(() => {
+ madeCaches = [];
+ fakeInternalEventService = new FakeInternalEventService();
+ });
+
+ afterEach(() => {
+ madeCaches.forEach(cache => {
+ cache.dispose();
+ });
+ });
+
+ it('should connect on construct', () => {
+ makeCache();
+
+ expect(fakeInternalEventService._calls).toContainEqual(['on', ['quantumCacheUpdated', expect.anything(), { ignoreLocal: true }]]);
+ });
+
+ it('should disconnect on dispose', () => {
+ const cache = makeCache();
+
+ cache.dispose();
+
+ const callback = fakeInternalEventService._calls
+ .find(c => c[0] === 'on' && c[1][0] === 'quantumCacheUpdated')
+ ?.[1][1];
+ expect(fakeInternalEventService._calls).toContainEqual(['off', ['quantumCacheUpdated', callback]]);
+ });
+
+ it('should store in memory cache', async () => {
+ const cache = makeCache<string>();
+
+ await cache.set('foo', 'bar');
+ await cache.set('alpha', 'omega');
+
+ const result1 = await cache.get('foo');
+ const result2 = await cache.get('alpha');
+
+ expect(result1).toBe('bar');
+ expect(result2).toBe('omega');
+ });
+
+ it('should emit event when storing', async () => {
+ const cache = makeCache<string>({ name: 'fake' });
+
+ await cache.set('foo', 'bar');
+
+ expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
+ });
+
+ it('should call onChanged when storing', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.set('foo', 'bar');
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
+ });
+
+ it('should not emit event when storing unchanged value', async () => {
+ const cache = makeCache<string>({ name: 'fake' });
+
+ await cache.set('foo', 'bar');
+ await cache.set('foo', 'bar');
+
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
+ });
+
+ it('should not call onChanged when storing unchanged value', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.set('foo', 'bar');
+ await cache.set('foo', 'bar');
+
+ expect(fakeOnChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('should fetch an unknown value', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ });
+
+ const result = await cache.fetch('foo');
+
+ expect(result).toBe('value#foo');
+ });
+
+ it('should store fetched value in memory cache', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ });
+
+ await cache.fetch('foo');
+
+ const result = cache.has('foo');
+ expect(result).toBe(true);
+ });
+
+ it('should call onChanged when fetching', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.fetch('foo');
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
+ });
+
+ it('should not emit event when fetching', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ });
+
+ await cache.fetch('foo');
+
+ expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
+ });
+
+ it('should delete from memory cache', async () => {
+ const cache = makeCache<string>();
+
+ await cache.set('foo', 'bar');
+ await cache.delete('foo');
+
+ const result = cache.has('foo');
+ expect(result).toBe(false);
+ });
+
+ it('should call onChanged when deleting', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.set('foo', 'bar');
+ await cache.delete('foo');
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
+ });
+
+ it('should emit event when deleting', async () => {
+ const cache = makeCache<string>({ name: 'fake' });
+
+ await cache.set('foo', 'bar');
+ await cache.delete('foo');
+
+ expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
+ });
+
+ it('should delete when receiving set event', async () => {
+ const cache = makeCache<string>({ name: 'fake' });
+ await cache.set('foo', 'bar');
+
+ await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
+
+ const result = cache.has('foo');
+ expect(result).toBe(false);
+ });
+
+ it('should call onChanged when receiving set event', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
+ });
+
+ it('should delete when receiving delete event', async () => {
+ const cache = makeCache<string>({ name: 'fake' });
+ await cache.set('foo', 'bar');
+
+ await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
+
+ const result = cache.has('foo');
+ expect(result).toBe(false);
+ });
+
+ it('should call onChanged when receiving delete event', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+ await cache.set('foo', 'bar');
+
+ await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
+ });
+
+ describe('get', () => {
+ it('should return value if present', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ const result = cache.get('foo');
+
+ expect(result).toBe('bar');
+ });
+ it('should return undefined if missing', () => {
+ const cache = makeCache<string>();
+
+ const result = cache.get('foo');
+
+ expect(result).toBe(undefined);
+ });
+ });
+
+ describe('setMany', () => {
+ it('should populate all values', async () => {
+ const cache = makeCache<string>();
+
+ await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
+
+ expect(cache.has('foo')).toBe(true);
+ expect(cache.has('alpha')).toBe(true);
+ });
+
+ it('should emit one event', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ });
+
+ await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
+
+ expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]);
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
+ });
+
+ it('should call onChanged once with all items', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
+ expect(fakeOnChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit events only for changed items', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.set('foo', 'bar');
+ fakeOnChanged.mockClear();
+ fakeInternalEventService._reset();
+
+ await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
+
+ expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['alpha'] }]]);
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
+ expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache);
+ expect(fakeOnChanged).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('getMany', () => {
+ it('should return empty for empty input', () => {
+ const cache = makeCache();
+ const result = cache.getMany([]);
+ expect(result).toEqual([]);
+ });
+
+ it('should return the value for all keys', () => {
+ const cache = makeCache();
+ cache.add('foo', 'bar');
+ cache.add('alpha', 'omega');
+
+ const result = cache.getMany(['foo', 'alpha']);
+
+ expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]);
+ });
+
+ it('should return undefined for missing keys', () => {
+ const cache = makeCache();
+ cache.add('foo', 'bar');
+
+ const result = cache.getMany(['foo', 'alpha']);
+
+ expect(result).toEqual([['foo', 'bar'], ['alpha', undefined]]);
+ });
+ });
+
+ describe('fetchMany', () => {
+ it('should do nothing for empty input', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.fetchMany([]);
+
+ expect(fakeOnChanged).not.toHaveBeenCalled();
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
+ });
+
+ it('should return existing items', async () => {
+ const cache = makeCache();
+ cache.add('foo', 'bar');
+ cache.add('alpha', 'omega');
+
+ const result = await cache.fetchMany(['foo', 'alpha']);
+
+ expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]);
+ });
+
+ it('should return existing items without events', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ onChanged: fakeOnChanged,
+ });
+ cache.add('foo', 'bar');
+ cache.add('alpha', 'omega');
+
+ await cache.fetchMany(['foo', 'alpha']);
+
+ expect(fakeOnChanged).not.toHaveBeenCalled();
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
+ });
+
+ it('should call bulkFetcher for missing items', async () => {
+ const cache = makeCache({
+ bulkFetcher: keys => keys.map(k => [k, `${k}#many`]),
+ fetcher: key => `${key}#single`,
+ });
+
+ const results = await cache.fetchMany(['foo', 'alpha']);
+
+ expect(results).toEqual([['foo', 'foo#many'], ['alpha', 'alpha#many']]);
+ });
+
+ it('should call bulkFetcher only once', async () => {
+ const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string]));
+ const cache = makeCache({
+ bulkFetcher: mockBulkFetcher,
+ });
+
+ await cache.fetchMany(['foo', 'bar']);
+
+ expect(mockBulkFetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call fetcher when fetchMany is undefined', async () => {
+ const cache = makeCache({
+ fetcher: key => `${key}#single`,
+ });
+
+ const results = await cache.fetchMany(['foo', 'alpha']);
+
+ expect(results).toEqual([['foo', 'foo#single'], ['alpha', 'alpha#single']]);
+ });
+
+ it('should call onChanged', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ onChanged: fakeOnChanged,
+ fetcher: k => k,
+ });
+
+ await cache.fetchMany(['foo', 'alpha']);
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
+ expect(fakeOnChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onChanged only for changed', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ onChanged: fakeOnChanged,
+ fetcher: k => k,
+ });
+ cache.add('foo', 'bar');
+
+ await cache.fetchMany(['foo', 'alpha']);
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache);
+ expect(fakeOnChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not emit event', async () => {
+ const cache = makeCache({
+ fetcher: k => k,
+ });
+
+ await cache.fetchMany(['foo', 'alpha']);
+
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
+ });
+ });
+
+ describe('refreshMany', () => {
+ it('should do nothing for empty input', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ onChanged: fakeOnChanged,
+ });
+
+ const result = await cache.refreshMany([]);
+
+ expect(result).toEqual([]);
+ expect(fakeOnChanged).not.toHaveBeenCalled();
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
+ });
+
+ it('should call bulkFetcher for all keys', async () => {
+ const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string]));
+ const cache = makeCache({
+ bulkFetcher: mockBulkFetcher,
+ });
+
+ const result = await cache.refreshMany(['foo', 'alpha']);
+
+ expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]);
+ expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache);
+ expect(mockBulkFetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('should replace any existing keys', async () => {
+ const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string]));
+ const cache = makeCache({
+ bulkFetcher: mockBulkFetcher,
+ });
+ cache.add('foo', 'bar');
+
+ const result = await cache.refreshMany(['foo', 'alpha']);
+
+ expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]);
+ expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache);
+ expect(mockBulkFetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onChanged for all keys', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ bulkFetcher: keys => keys.map(k => [k, `${k}#value`]),
+ onChanged: fakeOnChanged,
+ });
+ cache.add('foo', 'bar');
+
+ await cache.refreshMany(['foo', 'alpha']);
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
+ expect(fakeOnChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit event for all keys', async () => {
+ const cache = makeCache({
+ name: 'fake',
+ bulkFetcher: keys => keys.map(k => [k, `${k}#value`]),
+ });
+ cache.add('foo', 'bar');
+
+ await cache.refreshMany(['foo', 'alpha']);
+
+ expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]);
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
+ });
+ });
+
+ describe('deleteMany', () => {
+ it('should remove keys from memory cache', async () => {
+ const cache = makeCache<string>();
+
+ await cache.set('foo', 'bar');
+ await cache.set('alpha', 'omega');
+ await cache.deleteMany(['foo', 'alpha']);
+
+ expect(cache.has('foo')).toBe(false);
+ expect(cache.has('alpha')).toBe(false);
+ });
+
+ it('should emit only one event', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ });
+
+ await cache.deleteMany(['foo', 'alpha']);
+
+ expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]);
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
+ });
+
+ it('should call onChanged once with all items', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.deleteMany(['foo', 'alpha']);
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
+ expect(fakeOnChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('should do nothing if no keys are provided', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.deleteMany([]);
+
+ expect(fakeOnChanged).not.toHaveBeenCalled();
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
+ });
+ });
+
+ describe('refresh', () => {
+ it('should populate the value', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ });
+
+ await cache.refresh('foo');
+
+ const result = cache.has('foo');
+ expect(result).toBe(true);
+ });
+
+ it('should return the value', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ });
+
+ const result = await cache.refresh('foo');
+
+ expect(result).toBe('value#foo');
+ });
+
+ it('should replace the value if it exists', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ });
+
+ await cache.set('foo', 'bar');
+ const result = await cache.refresh('foo');
+
+ expect(result).toBe('value#foo');
+ });
+
+ it('should call onChanged', async () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ onChanged: fakeOnChanged,
+ });
+
+ await cache.refresh('foo');
+
+ expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
+ });
+
+ it('should emit event', async () => {
+ const cache = makeCache<string>({
+ name: 'fake',
+ fetcher: key => `value#${key}`,
+ });
+
+ await cache.refresh('foo');
+
+ expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
+ });
+ });
+
+ describe('add', () => {
+ it('should add the item', () => {
+ const cache = makeCache();
+ cache.add('foo', 'bar');
+ expect(cache.has('foo')).toBe(true);
+ });
+
+ it('should not emit event', () => {
+ const cache = makeCache({
+ name: 'fake',
+ });
+
+ cache.add('foo', 'bar');
+
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
+ });
+
+ it('should not call onChanged', () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ onChanged: fakeOnChanged,
+ });
+
+ cache.add('foo', 'bar');
+
+ expect(fakeOnChanged).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('addMany', () => {
+ it('should add all items', () => {
+ const cache = makeCache();
+
+ cache.addMany([['foo', 'bar'], ['alpha', 'omega']]);
+
+ expect(cache.has('foo')).toBe(true);
+ expect(cache.has('alpha')).toBe(true);
+ });
+
+ it('should not emit event', () => {
+ const cache = makeCache({
+ name: 'fake',
+ });
+
+ cache.addMany([['foo', 'bar'], ['alpha', 'omega']]);
+
+ expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
+ });
+
+ it('should not call onChanged', () => {
+ const fakeOnChanged = jest.fn(() => Promise.resolve());
+ const cache = makeCache({
+ onChanged: fakeOnChanged,
+ });
+
+ cache.addMany([['foo', 'bar'], ['alpha', 'omega']]);
+
+ expect(fakeOnChanged).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('has', () => {
+ it('should return false when empty', () => {
+ const cache = makeCache();
+ const result = cache.has('foo');
+ expect(result).toBe(false);
+ });
+
+ it('should return false when value is not in memory', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ const result = cache.has('alpha');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when value is in memory', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ const result = cache.has('foo');
+
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('size', () => {
+ it('should return 0 when empty', () => {
+ const cache = makeCache();
+ expect(cache.size).toBe(0);
+ });
+
+ it('should return correct size when populated', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ expect(cache.size).toBe(1);
+ });
+ });
+
+ describe('entries', () => {
+ it('should return empty when empty', () => {
+ const cache = makeCache();
+
+ const result = Array.from(cache.entries());
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should return all entries when populated', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ const result = Array.from(cache.entries());
+
+ expect(result).toEqual([['foo', 'bar']]);
+ });
+ });
+
+ describe('keys', () => {
+ it('should return empty when empty', () => {
+ const cache = makeCache();
+
+ const result = Array.from(cache.keys());
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should return all keys when populated', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ const result = Array.from(cache.keys());
+
+ expect(result).toEqual(['foo']);
+ });
+ });
+
+ describe('values', () => {
+ it('should return empty when empty', () => {
+ const cache = makeCache();
+
+ const result = Array.from(cache.values());
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should return all values when populated', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ const result = Array.from(cache.values());
+
+ expect(result).toEqual(['bar']);
+ });
+ });
+
+ describe('[Symbol.iterator]', () => {
+ it('should return empty when empty', () => {
+ const cache = makeCache();
+
+ const result = Array.from(cache);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should return all entries when populated', async () => {
+ const cache = makeCache<string>();
+ await cache.set('foo', 'bar');
+
+ const result = Array.from(cache);
+
+ expect(result).toEqual([['foo', 'bar']]);
+ });
+ });
+});
diff --git a/packages/backend/test/unit/misc/diff-arrays.ts b/packages/backend/test/unit/misc/diff-arrays.ts
new file mode 100644
index 0000000000..b6db5e2eca
--- /dev/null
+++ b/packages/backend/test/unit/misc/diff-arrays.ts
@@ -0,0 +1,91 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js';
+
+describe(diffArrays, () => {
+ it('should return empty result when both inputs are null', () => {
+ const result = diffArrays(null, null);
+ expect(result.added).toHaveLength(0);
+ expect(result.removed).toHaveLength(0);
+ });
+
+ it('should return empty result when both inputs are empty', () => {
+ const result = diffArrays([], []);
+ expect(result.added).toHaveLength(0);
+ expect(result.removed).toHaveLength(0);
+ });
+
+ it('should remove before when after is empty', () => {
+ const result = diffArrays([1, 2, 3], []);
+ expect(result.added).toHaveLength(0);
+ expect(result.removed).toEqual([1, 2, 3]);
+ });
+
+ it('should deduplicate before when after is empty', () => {
+ const result = diffArrays([1, 1, 2, 2, 3], []);
+ expect(result.added).toHaveLength(0);
+ expect(result.removed).toEqual([1, 2, 3]);
+ });
+
+ it('should add after when before is empty', () => {
+ const result = diffArrays([], [1, 2, 3]);
+ expect(result.added).toEqual([1, 2, 3]);
+ expect(result.removed).toHaveLength(0);
+ });
+
+ it('should deduplicate after when before is empty', () => {
+ const result = diffArrays([], [1, 1, 2, 2, 3]);
+ expect(result.added).toEqual([1, 2, 3]);
+ expect(result.removed).toHaveLength(0);
+ });
+
+ it('should return diff when both have values', () => {
+ const result = diffArrays(
+ ['a', 'b', 'c', 'd'],
+ ['a', 'c', 'e', 'f'],
+ );
+ expect(result.added).toEqual(['e', 'f']);
+ expect(result.removed).toEqual(['b', 'd']);
+ });
+});
+
+describe(diffArraysSimple, () => {
+ it('should return false when both inputs are null', () => {
+ const result = diffArraysSimple(null, null);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when both inputs are empty', () => {
+ const result = diffArraysSimple([], []);
+ expect(result).toBe(false);
+ });
+
+ it('should return true when before is populated and after is empty', () => {
+ const result = diffArraysSimple([1, 2, 3], []);
+ expect(result).toBe(true);
+ });
+
+ it('should return true when before is empty and after is populated', () => {
+ const result = diffArraysSimple([], [1, 2, 3]);
+ expect(result).toBe(true);
+ });
+
+ it('should return true when values have changed', () => {
+ const result = diffArraysSimple(
+ ['a', 'a', 'b', 'c'],
+ ['a', 'b', 'c', 'd'],
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return false when values have not changed', () => {
+ const result = diffArraysSimple(
+ ['a', 'a', 'b', 'c'],
+ ['a', 'b', 'c', 'c'],
+ );
+ expect(result).toBe(false);
+ });
+});
diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts
index 24cd2236bb..b6cfa53466 100644
--- a/packages/backend/test/unit/misc/is-renote.ts
+++ b/packages/backend/test/unit/misc/is-renote.ts
@@ -40,10 +40,13 @@ const base: MiNote = {
channelId: null,
channel: null,
userHost: null,
+ userInstance: null,
replyUserId: null,
replyUserHost: null,
+ replyUserInstance: null,
renoteUserId: null,
renoteUserHost: null,
+ renoteUserInstance: null,
processErrors: [],
};
diff --git a/packages/backend/test/unit/misc/is-retryable-error.ts b/packages/backend/test/unit/misc/is-retryable-error.ts
index 096bf64d4f..6d241066f7 100644
--- a/packages/backend/test/unit/misc/is-retryable-error.ts
+++ b/packages/backend/test/unit/misc/is-retryable-error.ts
@@ -8,6 +8,9 @@ import { AbortError } from 'node-fetch';
import { isRetryableError } from '@/misc/is-retryable-error.js';
import { StatusError } from '@/misc/status-error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { CaptchaError, captchaErrorCodes } from '@/core/CaptchaService.js';
+import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
+import { ConflictError } from '@/server/SkRateLimiterService.js';
describe(isRetryableError, () => {
it('should return true for retryable StatusError', () => {
@@ -55,6 +58,78 @@ describe(isRetryableError, () => {
expect(result).toBeTruthy();
});
+ it('should return false for CaptchaError with verificationFailed', () => {
+ const error = new CaptchaError(captchaErrorCodes.verificationFailed, 'verificationFailed');
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
+ it('should return false for CaptchaError with invalidProvider', () => {
+ const error = new CaptchaError(captchaErrorCodes.invalidProvider, 'invalidProvider');
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
+ it('should return false for CaptchaError with invalidParameters', () => {
+ const error = new CaptchaError(captchaErrorCodes.invalidParameters, 'invalidParameters');
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
+ it('should return true for CaptchaError with noResponseProvided', () => {
+ const error = new CaptchaError(captchaErrorCodes.noResponseProvided, 'noResponseProvided');
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return true for CaptchaError with requestFailed', () => {
+ const error = new CaptchaError(captchaErrorCodes.requestFailed, 'requestFailed');
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return true for CaptchaError with unknown', () => {
+ const error = new CaptchaError(captchaErrorCodes.unknown, 'unknown');
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return true for CaptchaError with any other', () => {
+ const error = new CaptchaError(Symbol('temp'), 'unknown');
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return false for FastifyReplyError', () => {
+ const error = new FastifyReplyError(400, 'test error');
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
+ it('should return true for ConflictError', () => {
+ const error = new ConflictError('test error');
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return true for AggregateError when all inners are retryable', () => {
+ const error = new AggregateError([
+ new ConflictError(),
+ new ConflictError(),
+ ]);
+ const result = isRetryableError(error);
+ expect(result).toBeTruthy();
+ });
+
+ it('should return true for AggregateError when any error is not retryable', () => {
+ const error = new AggregateError([
+ new ConflictError(),
+ new StatusError('test err', 400),
+ ]);
+ const result = isRetryableError(error);
+ expect(result).toBeFalsy();
+ });
+
const nonErrorInputs = [
[null, 'null'],
[undefined, 'undefined'],
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 7f2768488f..52cfb8ac93 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -11,12 +11,12 @@ import { inspect } from 'node:util';
import WebSocket, { ClientOptions } from 'ws';
import fetch, { File, RequestInit, type Headers } from 'node-fetch';
import { DataSource } from 'typeorm';
-import { load as cheerio } from 'cheerio';
+import { load as cheerio } from 'cheerio/slim';
import { type Response } from 'node-fetch';
import Fastify from 'fastify';
import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js';
-import type { CheerioAPI } from 'cheerio';
+import type { CheerioAPI } from 'cheerio/slim';
import type * as misskey from 'misskey-js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
@@ -652,7 +652,7 @@ export async function sendEnvResetRequest() {
// 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。
// FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する
-export function castAsError(obj: Record<string, unknown>): { error: ApiError } {
+export function castAsError(obj: object | null | undefined): { error: ApiError } {
return obj as { error: ApiError };
}