mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(user): push guild member updates on profile field changes (#2850)
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
||||
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
|
||||
|
||||
interface AdminUserUpdatePropagatorDeps {
|
||||
@@ -26,41 +25,11 @@ export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
});
|
||||
}
|
||||
|
||||
async propagateUserUpdate({
|
||||
userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
}: {
|
||||
userId: UserID;
|
||||
oldUser: User;
|
||||
updatedUser: User;
|
||||
}): Promise<void> {
|
||||
async propagateUserUpdate(params: {userId: UserID; oldUser: User; updatedUser: User}): Promise<void> {
|
||||
const {oldUser, updatedUser} = params;
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
|
||||
await this.updateUserCache(updatedUser);
|
||||
await this.propagateToGuilds(userId);
|
||||
await propagatePartialUserChange(this.deps, updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
private async propagateToGuilds(userId: UserID): Promise<void> {
|
||||
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requestCache = createRequestCache();
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {updateUserCache} from '@app/api/user/UserCacheHelpers';
|
||||
|
||||
interface UserGuildIdReader {
|
||||
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
|
||||
}
|
||||
|
||||
export interface PartialUserChangePropagationDeps {
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
userRepository: UserGuildIdReader;
|
||||
guildRepository: Pick<IGuildRepositoryAggregate, 'getMember'>;
|
||||
}
|
||||
|
||||
export async function propagatePartialUserChange(deps: PartialUserChangePropagationDeps, user: User): Promise<void> {
|
||||
const {userCacheService, gatewayService, userRepository, guildRepository} = deps;
|
||||
await updateUserCache({user, userCacheService});
|
||||
const guildIds = await userRepository.getUserGuildIds(user.id);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requestCache = createRequestCache();
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, user.id);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
requestCache.clear();
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export class UserAccountLifecycleService {
|
||||
if (updatedUser) {
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.deps.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export class UserAccountLifecycleService {
|
||||
if (updatedUser) {
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.deps.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export class UserAccountService {
|
||||
gatewayService,
|
||||
mediaService,
|
||||
userRepository: userAccountRepository,
|
||||
guildRepository,
|
||||
});
|
||||
this.lookupService = new UserAccountLookupService({
|
||||
userAccountRepository,
|
||||
@@ -193,7 +194,7 @@ export class UserAccountService {
|
||||
() => this.updatePropagator.dispatchUserUpdate(updatedUser),
|
||||
async () => {
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
@@ -240,7 +241,7 @@ export class UserAccountService {
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@app/api/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@app/api/models/UserSettings';
|
||||
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
||||
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
||||
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@app/api/user/UserMappers';
|
||||
|
||||
interface UserAccountUpdatePropagatorDeps {
|
||||
@@ -15,6 +18,7 @@ interface UserAccountUpdatePropagatorDeps {
|
||||
gatewayService: IGatewayService;
|
||||
mediaService: IMediaService;
|
||||
userRepository: IUserAccountRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
}
|
||||
|
||||
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
@@ -25,6 +29,10 @@ export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
});
|
||||
}
|
||||
|
||||
async propagatePartialUserChange(user: User): Promise<void> {
|
||||
await propagatePartialUserChange(this.deps, user);
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {GuildMember} from '@app/api/models/GuildMember';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {
|
||||
type PartialUserChangePropagationDeps,
|
||||
propagatePartialUserChange,
|
||||
} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const USER_ID = createUserID(1n);
|
||||
const GUILD_IDS = [createGuildID(10n), createGuildID(11n)];
|
||||
const USER = {id: USER_ID} as unknown as User;
|
||||
|
||||
const USER_PARTIAL: UserPartialResponse = {
|
||||
id: USER_ID.toString(),
|
||||
username: 'ada',
|
||||
discriminator: '0001',
|
||||
global_name: null,
|
||||
avatar: 'newhash',
|
||||
avatar_color: null,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
function createMember(): GuildMember {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
nickname: null,
|
||||
avatarHash: null,
|
||||
bannerHash: null,
|
||||
accentColor: null,
|
||||
roleIds: new Set(),
|
||||
joinedAt: new Date(0),
|
||||
isMute: false,
|
||||
isDeaf: false,
|
||||
communicationDisabledUntil: null,
|
||||
profileFlags: 0,
|
||||
mentionFlags: 0,
|
||||
isPremiumSanitized: false,
|
||||
} as unknown as GuildMember;
|
||||
}
|
||||
|
||||
function createDeps(guildIds: Array<(typeof GUILD_IDS)[number]> = GUILD_IDS) {
|
||||
const dispatchGuild = vi.fn().mockResolvedValue(undefined);
|
||||
const setUserPartialResponseFromUser = vi.fn().mockResolvedValue(USER_PARTIAL);
|
||||
const getUserGuildIds = vi.fn().mockResolvedValue(guildIds);
|
||||
const getMember = vi.fn().mockResolvedValue(createMember());
|
||||
const deps: PartialUserChangePropagationDeps = {
|
||||
userCacheService: {
|
||||
setUserPartialResponseFromUser,
|
||||
getUserPartialResponse: async () => USER_PARTIAL,
|
||||
} as unknown as UserCacheService,
|
||||
gatewayService: {dispatchGuild} as unknown as IGatewayService,
|
||||
userRepository: {getUserGuildIds},
|
||||
guildRepository: {getMember} as unknown as IGuildRepositoryAggregate,
|
||||
};
|
||||
return {deps, dispatchGuild, setUserPartialResponseFromUser, getUserGuildIds, getMember};
|
||||
}
|
||||
|
||||
describe('propagatePartialUserChange', () => {
|
||||
test('invalidates the users service cache and pushes the new partial to every guild', async () => {
|
||||
const {deps, dispatchGuild, setUserPartialResponseFromUser} = createDeps();
|
||||
|
||||
await propagatePartialUserChange(deps, USER);
|
||||
|
||||
expect(setUserPartialResponseFromUser).toHaveBeenCalledWith(USER);
|
||||
expect(dispatchGuild).toHaveBeenCalledTimes(2);
|
||||
for (const [index, guildId] of GUILD_IDS.entries()) {
|
||||
const call = dispatchGuild.mock.calls[index]![0];
|
||||
expect(call.guildId).toBe(guildId);
|
||||
expect(call.event).toBe('GUILD_MEMBER_UPDATE');
|
||||
expect((call.data as {user: UserPartialResponse}).user).toEqual(USER_PARTIAL);
|
||||
}
|
||||
});
|
||||
|
||||
test('dispatches nothing when the user is in no guilds', async () => {
|
||||
const {deps, dispatchGuild, getMember} = createDeps([]);
|
||||
|
||||
await propagatePartialUserChange(deps, USER);
|
||||
|
||||
expect(getMember).not.toHaveBeenCalled();
|
||||
expect(dispatchGuild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips guilds the user is no longer a member of', async () => {
|
||||
const {deps, dispatchGuild, getMember} = createDeps();
|
||||
getMember.mockResolvedValueOnce(null);
|
||||
|
||||
await propagatePartialUserChange(deps, USER);
|
||||
|
||||
expect(dispatchGuild).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchGuild.mock.calls[0]![0].guildId).toBe(GUILD_IDS[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestGuild, getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {updateAvatar} from '@app/api/user/tests/UserTestUtils';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
|
||||
|
||||
type DispatchGuildSpy = MockInstance<NoopGatewayService['dispatchGuild']>;
|
||||
|
||||
interface MemberUpdateDispatch {
|
||||
guildId: {toString(): string};
|
||||
event: string;
|
||||
data: GuildMemberResponse;
|
||||
}
|
||||
|
||||
function memberUpdatesFor(dispatchGuild: DispatchGuildSpy, guildId: string): Array<MemberUpdateDispatch> {
|
||||
return dispatchGuild.mock.calls
|
||||
.map(([params]) => params as unknown as MemberUpdateDispatch)
|
||||
.filter((params) => params.event === 'GUILD_MEMBER_UPDATE' && params.guildId.toString() === guildId);
|
||||
}
|
||||
|
||||
describe('User Profile Guild Propagation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('pushes the new avatar to every guild the user is in', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, account.token);
|
||||
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
|
||||
const updated = await updateAvatar(harness, account.token, getPngDataUrl());
|
||||
|
||||
expect(updated.avatar).toBeTruthy();
|
||||
const updates = memberUpdatesFor(dispatchGuild, guild.id);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]!.data.user.id).toBe(account.userId);
|
||||
expect(updates[0]!.data.user.avatar).toBe(updated.avatar);
|
||||
});
|
||||
|
||||
it('does not push a member update when no partial field changed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, account.token);
|
||||
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
|
||||
await updateAvatar(harness, account.token, null);
|
||||
|
||||
expect(memberUpdatesFor(dispatchGuild, guild.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -46,9 +46,6 @@ const CURRENT_USER_PRIVATE_WIRE_KEYS = [
|
||||
] as const;
|
||||
|
||||
function isPublicOnlyCurrentUserPayload(user: WireUser): boolean {
|
||||
if (typeof user.mention_flags === 'number') {
|
||||
return false;
|
||||
}
|
||||
return !CURRENT_USER_PRIVATE_WIRE_KEYS.some((key) => key in user);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user