Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/account/account-followed.event.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Account } from 'account/types';
import type { Account } from './account.entity';

export class AccountFollowedEvent {
constructor(
Expand Down
97 changes: 76 additions & 21 deletions src/account/account.service.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
InternalAccountData,
Site,
} from './types';
import { asAccountEntity } from './utils';

vi.mock('@fedify/fedify', async () => {
// generateCryptoKeyPair is a slow operation so we generate a key pair
Expand Down Expand Up @@ -87,7 +88,7 @@ describe('AccountService', () => {
username: 'index',
name: 'Test Site Title',
bio: 'Test Site Description',
avatar_url: 'Test Site Icon',
avatar_url: 'https://example.com/avatars/internal-account.png',
};
externalAccountData = {
username: 'external-account',
Expand Down Expand Up @@ -214,7 +215,10 @@ describe('AccountService', () => {
username: 'follower',
});

await service.recordAccountFollow(account, follower);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower),
);

// Assert the follow was inserted into the database
const follows = await db('follows').select('*');
Expand Down Expand Up @@ -245,7 +249,10 @@ describe('AccountService', () => {
username: 'follower',
});

await service.recordAccountFollow(account, follower);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower),
);

// Assert the follow was inserted into the database
const follows = await db('follows').select('*');
Expand All @@ -268,11 +275,17 @@ describe('AccountService', () => {
username: 'follower',
});

await service.recordAccountFollow(account, follower);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower),
);

const firstFollow = await db('follows').where({ id: 1 }).first();

await service.recordAccountFollow(account, follower);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower),
);

// Assert the follow was inserted into the database only once
const follows = await db('follows').select('*');
Expand Down Expand Up @@ -304,15 +317,18 @@ describe('AccountService', () => {
username: 'follower',
});

await service.recordAccountFollow(account, follower);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower),
);

await vi.waitFor(() => {
return accountFollowedEvent !== undefined;
});

expect(accountFollowedEvent).toBeDefined();
expect(accountFollowedEvent?.getAccount()).toBe(account);
expect(accountFollowedEvent?.getFollower()).toBe(follower);
expect(accountFollowedEvent?.getAccount().id).toBe(account.id);
expect(accountFollowedEvent?.getFollower().id).toBe(follower.id);
});

it('should not emit an account.followed event if the follow is not recorded due to being a duplicate', async () => {
Expand All @@ -333,8 +349,14 @@ describe('AccountService', () => {
username: 'follower',
});

await service.recordAccountFollow(account, follower);
await service.recordAccountFollow(account, follower);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower),
);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower),
);

await vi.advanceTimersByTime(1000);

Expand Down Expand Up @@ -428,9 +450,18 @@ describe('AccountService', () => {
username: 'following3',
});

await service.recordAccountFollow(following1, account);
await service.recordAccountFollow(following2, account);
await service.recordAccountFollow(following3, account);
await service.recordAccountFollow(
asAccountEntity(following1),
asAccountEntity(account),
);
await service.recordAccountFollow(
asAccountEntity(following2),
asAccountEntity(account),
);
await service.recordAccountFollow(
asAccountEntity(following3),
asAccountEntity(account),
);

// Get a page of following accounts and assert the requested fields are returned
const followingAccounts = await service.getFollowingAccounts(
Expand Down Expand Up @@ -500,8 +531,14 @@ describe('AccountService', () => {
username: 'following2',
});

await service.recordAccountFollow(following1, account);
await service.recordAccountFollow(following2, account);
await service.recordAccountFollow(
asAccountEntity(following1),
asAccountEntity(account),
);
await service.recordAccountFollow(
asAccountEntity(following2),
asAccountEntity(account),
);

const count = await service.getFollowingAccountsCount(account);

Expand All @@ -528,9 +565,18 @@ describe('AccountService', () => {
username: 'follower3',
});

await service.recordAccountFollow(account, follower1);
await service.recordAccountFollow(account, follower2);
await service.recordAccountFollow(account, follower3);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower1),
);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower2),
);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower3),
);

// Get a page of followers and assert the requested fields are returned
const followers = await service.getFollowerAccounts(account, {
Expand Down Expand Up @@ -596,8 +642,14 @@ describe('AccountService', () => {
username: 'follower2',
});

await service.recordAccountFollow(account, follower1);
await service.recordAccountFollow(account, follower2);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower1),
);
await service.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower2),
);

const count = await service.getFollowerAccountsCount(account);

Expand All @@ -620,7 +672,10 @@ describe('AccountService', () => {
username: 'non-followee',
});

await service.recordAccountFollow(followee, account);
await service.recordAccountFollow(
asAccountEntity(followee),
asAccountEntity(account),
);

const isFollowing = await service.checkIfAccountIsFollowing(
account,
Expand Down
10 changes: 7 additions & 3 deletions src/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,18 @@ export class AccountService {
async createExternalAccount(
accountData: ExternalAccountData,
): Promise<AccountType> {
const uuid = randomUUID();

const [accountId] = await this.db('accounts').insert({
...accountData,
uuid: randomUUID(),
uuid,
});

return {
id: accountId,
...accountData,
ap_private_key: null,
uuid,
};
}

Expand All @@ -170,8 +173,8 @@ export class AccountService {
* @param follower Following account
*/
async recordAccountFollow(
followee: AccountType,
follower: AccountType,
followee: Account,
follower: Account,
): Promise<void> {
const [insertCount] = await this.db('follows')
.insert({
Expand Down Expand Up @@ -415,6 +418,7 @@ export class AccountService {
ap_liked_url: row.ap_liked_url,
ap_public_key: row.ap_public_key,
ap_private_key: row.ap_private_key,
uuid: row.uuid,
};
}

Expand Down
6 changes: 5 additions & 1 deletion src/account/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,13 @@ export interface Account {
ap_liked_url: string;
ap_public_key: string;
ap_private_key: string | null;
uuid: string | null;
}

/**
* Data used when creating an external account
*/
export type ExternalAccountData = Omit<Account, 'id' | 'ap_private_key'>;
export type ExternalAccountData = Omit<
Account,
'id' | 'ap_private_key' | 'uuid'
>;
24 changes: 23 additions & 1 deletion src/account/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Actor, PropertyValue } from '@fedify/fedify';
import type { ExternalAccountData } from './types';
import { Account } from './account.entity';
import type { Account as AccountType, ExternalAccountData } from './types';

interface PublicKey {
id: string;
Expand Down Expand Up @@ -77,3 +78,24 @@ export async function mapActorToExternalAccountData(
export function getAccountHandle(host?: string, username?: string) {
return `@${username || 'unknown'}@${host || 'unknown'}`;
}

/**
* Convert an account type to an account entity
*
* @param account Account type
*/
export function asAccountEntity(account: AccountType): Account {
return new Account(
account.id,
account.uuid,
account.username,
account.name,
account.bio,
account.avatar_url ? new URL(account.avatar_url) : null,
account.banner_image_url ? new URL(account.banner_image_url) : null,
null, // site
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AccountType doesn't have the site data, so we should in theory be safe to leave this as null as anywhere that is using AccountType currently won't be accessing the site. We could pass the site as another argument to the fn if needed?

new URL(account.ap_id),
account.url ? new URL(account.url) : null,
new URL(account.ap_followers_url),
);
}
1 change: 1 addition & 0 deletions src/activitypub/fediverse-bridge.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('FediverseBridge', () => {
ap_liked_url: 'https://account.com/liked',
ap_public_key: '{}',
ap_private_key: '{}',
uuid: null,
};

events.emit('account.updated', account);
Expand Down
18 changes: 14 additions & 4 deletions src/activitypub/followers.service.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KnexAccountRepository } from 'account/account.repository.knex';
import { AccountService } from 'account/account.service';
import { asAccountEntity } from 'account/utils';
import { AsyncEvents } from 'core/events';
import type { Knex } from 'knex';
import { generateTestCryptoKeyPair } from 'test/crypto-key-pair';
Expand Down Expand Up @@ -53,7 +54,7 @@ describe('FollowersService', () => {
username: 'index',
name: 'Test Site Title',
bio: 'Test Site Description',
avatar_url: 'Test Site Icon',
avatar_url: 'https://example.com/avatars/internal-account.png',
};
const account = await accountService.createInternalAccount(site, {
...internalAccountData,
Expand All @@ -72,9 +73,18 @@ describe('FollowersService', () => {
username: 'follower3',
});

await accountService.recordAccountFollow(account, follower1);
await accountService.recordAccountFollow(account, follower2);
await accountService.recordAccountFollow(account, follower3);
await accountService.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower1),
);
await accountService.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower2),
);
await accountService.recordAccountFollow(
asAccountEntity(account),
asAccountEntity(follower3),
);

const followers = await service.getFollowers(account.id);

Expand Down
13 changes: 8 additions & 5 deletions src/dispatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import type { KnexAccountRepository } from 'account/account.repository.knex';
import type { FollowersService } from 'activitypub/followers.service';
import { v4 as uuidv4 } from 'uuid';
import type { AccountService } from './account/account.service';
import { mapActorToExternalAccountData } from './account/utils';
import {
asAccountEntity,
mapActorToExternalAccountData,
} from './account/utils';
import { type ContextData, fedify } from './app';
import { ACTOR_DEFAULT_HANDLE } from './constants';
import { isFollowedByDefaultSiteAccount } from './helpers/activitypub/actor';
Expand Down Expand Up @@ -164,8 +167,8 @@ export function createFollowHandler(accountService: AccountService) {
}

await accountService.recordAccountFollow(
followeeAccount,
followerAccount,
asAccountEntity(followeeAccount),
asAccountEntity(followerAccount),
);
}

Expand Down Expand Up @@ -237,8 +240,8 @@ export function createAcceptHandler(accountService: AccountService) {
}

await accountService.recordAccountFollow(
followeeAccount,
followerAccount,
asAccountEntity(followeeAccount),
asAccountEntity(followerAccount),
);
}
};
Expand Down
Loading