This commit is contained in:
taichan 2026-01-29 18:25:10 +09:00 committed by GitHub
commit d635547f5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 531 additions and 27 deletions

View File

@ -1263,7 +1263,7 @@ v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`
- Fix: カスタム絵文字の画像読み込みに失敗した際はテキストではなくダミー画像を表示 #13487
### Server
-
- Fix: FTT有効かつDBフォールバック有効時、STLのようにタイムラインのソースが複数だとFTTとDBのフォールバック間で取得されないートがある問題
## 2024.3.0

View File

@ -14,6 +14,7 @@ import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
@ -59,6 +60,7 @@ export class FanoutTimelineEndpointService {
private fanoutTimelineService: FanoutTimelineService,
private utilityService: UtilityService,
private channelMutingService: ChannelMutingService,
private idService: IdService,
) {
}
@ -77,12 +79,37 @@ export class FanoutTimelineEndpointService {
const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId);
// 取得したredisResultのうち、2つ以上ソースがあり、1つでも空であればDBにフォールバックする
const trustedEmptyIndices = new Set<number>();
for (let i = 0; i < redisResult.length; i++) {
const ids = redisResult[i];
const dummyIdIndex = ids.findIndex(id => this.idService.parse(id).date.getTime() === 1);
if (dummyIdIndex !== -1) {
ids.splice(dummyIdIndex, 1);
if (ids.length === 0) {
trustedEmptyIndices.add(i);
}
}
}
let shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some((ids, i) => ids.length === 0 && !trustedEmptyIndices.has(i)));
// 取得したresultの中で最古のIDのうち、最も新しいものを取得
// ids自体が空配列の場合、ids[ids.length - 1]はundefinedになるため、filterでnullを除外する
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const fttThresholdId = redisResult.map(ids => ascending ? ids[0] : ids[ids.length - 1]).filter(id => id != null).sort().pop();
// TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい
const redisResultIds = Array.from(new Set(redisResult.flat(1))).sort(idCompare);
let redisResultIds = shouldFallbackToDb ? [] : Array.from(new Set(redisResult.flat(1)));
if (ps.useDbFallback && fttThresholdId != null) {
redisResultIds = redisResultIds.filter(id => id >= fttThresholdId);
}
redisResultIds.sort(idCompare);
let noteIds = redisResultIds.slice(0, ps.limit);
const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1];
const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId;
shouldFallbackToDb ||= ps.useDbFallback && (noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId);
if (!shouldFallbackToDb) {
let filter = ps.noteFilter ?? (_note => true) as NoteFilter;
@ -202,7 +229,33 @@ export class FanoutTimelineEndpointService {
return [...redisTimeline, ...gotFromDb];
}
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
// RedisおよびDBが空の場合、次回以降の無駄なDBアクセスを防ぐためダミーIDを保存する
const gotFromDb = await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
const canInject = (
(redisResultIds.length === 0 && ps.sinceId == null && ps.untilId == null) &&
(gotFromDb.length < ps.limit)
);
if (canInject) {
const dummyId = this.idService.gen(1); // 1 = Detectable Dummy Timestamp
Promise.all(ps.redisTimelines.map((tl, i) => {
// 有効なソースかつ結果が空だった場合のみダミーを入れる
if (redisResult[i] && redisResult[i].length === 0) {
let isEmpty = true;
if (gotFromDb.length > 0) {
isEmpty = !gotFromDb.some(n => this.accepts(tl, n));
}
if (isEmpty) {
return this.fanoutTimelineService.injectDummyIfEmpty(tl, dummyId);
}
}
return Promise.resolve();
}));
}
return gotFromDb;
}
private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
@ -221,4 +274,32 @@ export class FanoutTimelineEndpointService {
return notes;
}
private accepts(tl: FanoutTimelineName, note: MiNote): boolean {
if (tl === 'localTimeline') {
return !note.userHost && !note.replyId && note.visibility === 'public';
} else if (tl === 'localTimelineWithFiles') {
return !note.userHost && !note.replyId && note.visibility === 'public' && note.fileIds.length > 0;
} else if (tl === 'localTimelineWithReplies') {
return !note.userHost && note.replyId != null && note.visibility === 'public';
} else if (tl.startsWith('localTimelineWithReplyTo:')) {
const id = tl.split(':')[1];
return !note.userHost && note.replyId != null && note.replyUserId === id;
} else if (tl.startsWith('userTimeline:')) {
const id = tl.split(':')[1];
return note.userId === id && !note.replyId;
} else if (tl.startsWith('userTimelineWithFiles:')) {
const id = tl.split(':')[1];
return note.userId === id && !note.replyId && note.fileIds.length > 0;
} else if (tl.startsWith('userTimelineWithReplies:')) {
const id = tl.split(':')[1];
return note.userId === id && note.replyId != null;
} else if (tl.startsWith('userTimelineWithChannel:')) {
const id = tl.split(':')[1];
return note.userId === id && note.channelId != null;
} else {
// TODO: homeTimeline系
return true;
}
}
}

View File

@ -14,9 +14,9 @@ export type FanoutTimelineName = (
| `homeTimeline:${string}`
| `homeTimelineWithFiles:${string}` // only notes with files are included
// local timeline
| `localTimeline` // replies are not included
| `localTimelineWithFiles` // only non-reply notes with files are included
| `localTimelineWithReplies` // only replies are included
| 'localTimeline' // replies are not included
| 'localTimelineWithFiles' // only non-reply notes with files are included
| 'localTimelineWithReplies' // only replies are included
| `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id.
// antenna
@ -108,6 +108,21 @@ export class FanoutTimelineService {
});
}
@bindThis
injectDummy(tl: FanoutTimelineName, id: string) {
return this.redisForTimelines.lpush('list:' + tl, id);
}
@bindThis
public injectDummyIfEmpty(tl: FanoutTimelineName, id: string): Promise<boolean> {
return this.redisForTimelines.eval(
'if redis.call("LLEN", KEYS[1]) == 0 then redis.call("LPUSH", KEYS[1], ARGV[1]) return 1 else return 0 end',
1,
'list:' + tl,
id,
).then(res => res === 1);
}
@bindThis
public purge(name: FanoutTimelineName) {
return this.redisForTimelines.del('list:' + name);

View File

@ -732,7 +732,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis
public getFollowees(userId: MiUser['id']) {
return this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.select(['following.followeeId', 'following.withReplies'])
.where('following.followerId = :followerId', { followerId: userId })
.getMany();
}

View File

@ -197,6 +197,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withReplies: boolean,
}, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const followeeIds = followees.map(f => f.followeeId);
const meOrFolloweeIds = [me.id, ...followeeIds];
const followeeWithRepliesIds = followees.filter(f => f.withReplies).map(f => f.followeeId);
const meOrFolloweeWithRepliesIds = [...meOrFolloweeIds, ...followeeWithRepliesIds];
const mutingChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
@ -207,14 +211,39 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => {
// 自分自身
qb.where('note.userId = :meId', { meId: me.id });
// フォローしている人
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
} else {
qb.where('note.userId = :meId', { meId: me.id });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
qb.orWhere(new Brackets(qb => {
qb.where('note.userId IN (:...followeeIds)', { followeeIds });
// 自身に関係ないリプライを除外
if (ps.withReplies) {
qb.andWhere(new Brackets(qb => {
qb.where('note.replyId IS NULL')
.orWhere('note.replyUserId IN (:...meOrFolloweeWithRepliesIds)', { meOrFolloweeWithRepliesIds });
if (followeeWithRepliesIds.length > 0) {
qb.orWhere(new Brackets(qb => {
qb.where('note.userId IN (:...followeeWithRepliesIds)', { followeeWithRepliesIds })
.andWhere(new Brackets(qb => {
qb.where('reply.visibility != \'followers\'')
.orWhere('note.replyUserId IN (:...followeeIds)', { followeeIds });
}));
}));
}
}));
}
}));
}
// ローカルのpublicート
qb.orWhere(new Brackets(qb => {
qb.where('note.visibility = \'public\'')
.andWhere('note.userHost IS NULL');
}));
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
@ -246,7 +275,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}))
.orWhere('note.replyUserId = :meId', { meId: me.id }); // 自分への返信
}));
}

View File

@ -1664,9 +1664,6 @@ describe('Timelines', () => {
});
test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => {
/* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */
if (!enableFanoutTimeline) return;
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('following/create', { userId: bob.id }, alice);
@ -1694,16 +1691,13 @@ describe('Timelines', () => {
await waitForPushToTl();
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice);
const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => {
/* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */
if (!enableFanoutTimeline) return;
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('following/create', { userId: bob.id }, alice);
@ -1716,7 +1710,7 @@ describe('Timelines', () => {
await waitForPushToTl();
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice);
const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
@ -1724,9 +1718,6 @@ describe('Timelines', () => {
});
test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => {
/* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */
if (!enableFanoutTimeline) return;
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('following/create', { userId: bob.id }, alice);
@ -1738,7 +1729,7 @@ describe('Timelines', () => {
await waitForPushToTl();
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice);
const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);

View File

@ -0,0 +1,302 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, jest, test, expect, beforeEach, afterEach, beforeAll, afterAll } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { FanoutTimelineService, FanoutTimelineName } from '@/core/FanoutTimelineService.js';
import { IdService } from '@/core/IdService.js';
import { NotesRepository, UsersRepository, UserProfilesRepository, MiUser, MiNote } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
describe('FanoutTimelineEndpointService', () => {
let app: TestingModule;
let service: FanoutTimelineEndpointService;
let fanoutTimelineService: jest.Mocked<FanoutTimelineService>;
let notesRepository: NotesRepository;
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
let idService: IdService;
let alice: MiUser;
async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
id: idService.gen(),
username: 'username',
usernameLower: 'username',
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
await userProfilesRepository.insert({
userId: user.id,
});
return user;
}
async function createNote(data: Partial<MiNote> = {}) {
return await notesRepository
.insert({
id: idService.gen(),
userId: alice.id,
text: 'test',
visibility: 'public',
localOnly: false,
...data,
})
.then(x => notesRepository.findOneByOrFail(x.identifiers[0]));
}
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
CoreModule,
],
providers: [
FanoutTimelineEndpointService,
],
})
.overrideProvider(FanoutTimelineService)
.useValue({
getMulti: jest.fn(),
injectDummy: jest.fn(),
injectDummyIfEmpty: jest.fn(),
})
.compile();
app.enableShutdownHooks();
service = app.get<FanoutTimelineEndpointService>(FanoutTimelineEndpointService);
fanoutTimelineService = app.get(FanoutTimelineService) as jest.Mocked<FanoutTimelineService>;
notesRepository = app.get<NotesRepository>(DI.notesRepository);
usersRepository = app.get<UsersRepository>(DI.usersRepository);
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
idService = app.get<IdService>(IdService);
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
alice = await createUser({ username: 'alice', usernameLower: 'alice' });
});
afterEach(async () => {
jest.clearAllMocks();
await notesRepository.deleteAll();
await userProfilesRepository.deleteAll();
await usersRepository.deleteAll();
});
test('should use correctly calculated threshold (Max of Oldest) when merging disjoint timelines', async () => {
const now = Date.now();
// HTL: Recent (T-2m to T-4m)
const htlNote1 = await createNote({ id: idService.gen(now - 1000 * 60 * 2) });
const htlNote2 = await createNote({ id: idService.gen(now - 1000 * 60 * 3) });
const htlNote3 = await createNote({ id: idService.gen(now - 1000 * 60 * 4) }); // End of HTL (T-4m)
const htlIds = [htlNote1.id, htlNote2.id, htlNote3.id];
// LTL: Old (T-60m to T-62m)
const ltlNote1 = await createNote({ id: idService.gen(now - 1000 * 60 * 60) });
const ltlNote2 = await createNote({ id: idService.gen(now - 1000 * 60 * 61) });
const ltlNote3 = await createNote({ id: idService.gen(now - 1000 * 60 * 62) });
const ltlIds = [ltlNote1.id, ltlNote2.id, ltlNote3.id];
// Mock FanoutTimelineService to return these IDs
fanoutTimelineService.getMulti.mockResolvedValue([htlIds, ltlIds]);
// dbFallback spy
const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[]));
const ps = {
redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[],
useDbFallback: true,
limit: 10,
allowPartial: false,
excludePureRenotes: false,
dbFallback,
noteFilter: () => false, // Simulate strict filtering (force fallback)
untilId: null,
sinceId: null,
};
await service.getMiNotes(ps);
expect(dbFallback).toHaveBeenCalled();
const callArgs = dbFallback.mock.calls[0];
const untilId = callArgs[0] as string;
// We expect untilId to be the HTL oldest (htlNote3.id), NOT the LTL newest (ltlNote1.id).
expect(untilId).toBe(htlNote3.id);
expect(untilId > ltlNote1.id).toBe(true);
});
test('should maintain correct pagination cursor when using sinceId (ascending)', async () => {
const now = Date.now();
// Ascending: Oldest to Newest.
const note1 = await createNote({ id: idService.gen(now - 3000) });
const note2 = await createNote({ id: idService.gen(now - 2000) });
const note3 = await createNote({ id: idService.gen(now - 1000) });
const ids = [note1.id, note2.id, note3.id];
fanoutTimelineService.getMulti.mockResolvedValue([ids]);
// Mock dbFallback to return empty array
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const dbFallback = jest.fn((untilId: string | null, sinceId: string | null, limit: number) => Promise.resolve([] as MiNote[]));
const ps = {
redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[],
useDbFallback: false, // Disable fallback to check Redis filtering logic directly
limit: 2,
allowPartial: true,
excludePureRenotes: false,
dbFallback,
untilId: null,
sinceId: idService.gen(now - 4000),
};
const result = await service.getMiNotes(ps);
expect(result).toHaveLength(2);
expect(result[0].id).toBe(note1.id);
expect(result[1].id).toBe(note2.id);
});
test('should not fallback to DB when useDbFallback is false even if insufficient notes', async () => {
const now = Date.now();
const note1 = await createNote({ id: idService.gen(now) });
const ids = [note1.id];
fanoutTimelineService.getMulti.mockResolvedValue([ids]);
const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[]));
const ps = {
redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[],
useDbFallback: false,
limit: 10,
allowPartial: false,
excludePureRenotes: false,
dbFallback,
noteFilter: () => false, // Filter out everything
untilId: null,
sinceId: null,
};
const result = await service.getMiNotes(ps);
expect(dbFallback).not.toHaveBeenCalled();
expect(result).toEqual([]);
});
test('should merge disjoint timelines correctly when useDbFallback is false', async () => {
const now = Date.now();
// TL1: Recent
const note1 = await createNote({ id: idService.gen(now - 1000) });
const note2 = await createNote({ id: idService.gen(now - 2000) });
// TL2: Old
const note3 = await createNote({ id: idService.gen(now - 5000) });
const note4 = await createNote({ id: idService.gen(now - 6000) });
const ids1 = [note1.id, note2.id];
const ids2 = [note3.id, note4.id];
fanoutTimelineService.getMulti.mockResolvedValue([ids1, ids2]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const dbFallback = jest.fn((untilId: string | null, sinceId: string | null, limit: number) => Promise.resolve([] as MiNote[]));
const ps = {
redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[],
useDbFallback: false,
limit: 10,
allowPartial: true,
excludePureRenotes: false,
dbFallback,
noteFilter: () => true, // Accept all
untilId: null,
sinceId: null,
};
const result = await service.getMiNotes(ps);
// With the fixed logic (skipping filter when !useDbFallback), all notes should be present.
expect(result).toHaveLength(4);
expect(result.map(n => n.id)).toEqual([note1.id, note2.id, note3.id, note4.id]);
});
// Test for dummy ID optimization
test('should inject dummy ID when DB fallback returns empty on initial load', async () => {
const redisResult: string[][] = [[], []]; // Empty timelines
fanoutTimelineService.getMulti.mockResolvedValue(redisResult);
// Mock dbFallback to return empty array
const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[]));
const ps = {
redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[],
useDbFallback: true,
limit: 10,
allowPartial: true,
excludePureRenotes: false,
dbFallback,
noteFilter: () => true,
untilId: null,
sinceId: null,
};
const result = await service.getMiNotes(ps);
expect(result).toEqual([]);
// Should have tried to inject dummy ID for both empty timelines
expect(fanoutTimelineService.injectDummyIfEmpty).toHaveBeenCalledTimes(2);
expect(fanoutTimelineService.injectDummyIfEmpty).toHaveBeenCalledWith(`homeTimeline:${alice.id}`, expect.any(String));
expect(fanoutTimelineService.injectDummyIfEmpty).toHaveBeenCalledWith('localTimeline', expect.any(String));
});
// Test for behavior when dummy ID exists
test('should return empty result when only dummy ID exists in Redis and DB has no newer data', async () => {
const now = Date.now();
const dummyId = idService.gen(now);
// Redis has only dummy ID
const redisResult: string[][] = [[dummyId]];
fanoutTimelineService.getMulti.mockResolvedValue(redisResult);
// Mock dbFallback (should be called to check for newer notes than the dummy ID)
const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[]));
const ps = {
redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[],
useDbFallback: true,
limit: 10,
allowPartial: false,
excludePureRenotes: false,
dbFallback,
noteFilter: () => true,
untilId: null,
sinceId: null,
};
const result = await service.getMiNotes(ps);
expect(result).toEqual([]);
// Fallback should be called to check for newer notes (ascending check from dummy ID)
expect(dbFallback).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,85 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, jest, test, expect, afterEach, beforeAll, afterAll } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import * as Redis from 'ioredis';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
describe('FanoutTimelineService', () => {
let app: TestingModule;
let service: FanoutTimelineService;
let redisForTimelines: jest.Mocked<Redis.Redis>;
let idService: IdService;
beforeAll(async () => {
app = await Test.createTestingModule({
providers: [
FanoutTimelineService,
{
provide: IdService,
useValue: {
parse: jest.fn(),
gen: jest.fn(),
},
},
{
provide: DI.redisForTimelines,
useValue: {
eval: jest.fn(),
lpush: jest.fn(),
lrange: jest.fn(),
del: jest.fn(),
pipeline: jest.fn(() => ({
lpush: jest.fn(),
ltrim: jest.fn(),
lrange: jest.fn(),
exec: jest.fn(),
})),
},
},
],
}).compile();
app.enableShutdownHooks();
service = app.get<FanoutTimelineService>(FanoutTimelineService);
redisForTimelines = app.get(DI.redisForTimelines);
idService = app.get<IdService>(IdService);
});
afterAll(async () => {
await app.close();
});
afterEach(async () => {
jest.clearAllMocks();
});
test('injectDummyIfEmpty should call Redis EVAL with correct script', async () => {
redisForTimelines.eval.mockResolvedValue(1);
const result = await service.injectDummyIfEmpty('homeTimeline:123', 'dummyId');
expect(redisForTimelines.eval).toHaveBeenCalledWith(
expect.stringContaining('if redis.call("LLEN", KEYS[1]) == 0 then'),
1,
'list:homeTimeline:123',
'dummyId',
);
expect(result).toBe(true);
});
test('injectDummyIfEmpty should return false if list is not empty', async () => {
redisForTimelines.eval.mockResolvedValue(0);
const result = await service.injectDummyIfEmpty('homeTimeline:123', 'dummyId');
expect(redisForTimelines.eval).toHaveBeenCalled();
expect(result).toBe(false);
});
});