Skip to content
Merged
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
13 changes: 13 additions & 0 deletions src/account/account-updated.event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Account } from './account.entity';

export class AccountUpdatedEvent {
static getName(): string {
return 'account.updated.event';
Copy link
Member

Choose a reason for hiding this comment

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

This could just be account.updated, but I think it would contradict the current event. Maybe something to clean up later?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah exactly, that name is already taken so I went with this - it's abstracted away in this method so easy to change later

}

constructor(private readonly account: Account) {}

getAccount(): Account {
return this.account;
}
}
61 changes: 57 additions & 4 deletions src/account/account.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,44 @@ export type AccountSite = {
host: string;
};

export interface ProfileUpdateParams {
name?: string | null;
bio?: string | null;
avatarUrl?: URL | null;
bannerImageUrl?: URL | null;
}

export class Account extends BaseEntity {
public readonly uuid: string;
public readonly url: URL;
public readonly apId: URL;
public readonly apFollowers: URL;

private _name: string | null;
private _bio: string | null;
private _avatarUrl: URL | null;
private _bannerImageUrl: URL | null;

constructor(
public readonly id: number | null,
uuid: string | null,
public readonly username: string,
public readonly name: string | null,
public readonly bio: string | null,
public readonly avatarUrl: URL | null,
public readonly bannerImageUrl: URL | null,
name: string | null,
bio: string | null,
avatarUrl: URL | null,
bannerImageUrl: URL | null,
private readonly site: AccountSite | null,
apId: URL | null,
url: URL | null,
apFollowers: URL | null,
) {
super(id);

this._name = name;
this._bio = bio;
this._avatarUrl = avatarUrl;
this._bannerImageUrl = bannerImageUrl;

if (uuid === null) {
this.uuid = randomUUID();
} else {
Expand All @@ -63,6 +82,40 @@ export class Account extends BaseEntity {
}
}

get name(): string | null {
return this._name;
}

get bio(): string | null {
return this._bio;
}

get avatarUrl(): URL | null {
return this._avatarUrl;
}

get bannerImageUrl(): URL | null {
return this._bannerImageUrl;
}

updateProfile(params: ProfileUpdateParams): void {
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to add username here? Or maybe that's coming as part of other changes(custom handles)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah that will come as part of the username change work

if (params.name !== undefined) {
this._name = params.name;
}

if (params.bio !== undefined) {
this._bio = params.bio;
}

if (params.avatarUrl !== undefined) {
this._avatarUrl = params.avatarUrl;
}

if (params.bannerImageUrl !== undefined) {
this._bannerImageUrl = params.bannerImageUrl;
}
}

get isInternal() {
return this.site !== null;
}
Expand Down
178 changes: 178 additions & 0 deletions src/account/account.entity.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,182 @@ describe('Account', () => {
'http://foobar.com/.ghost/activitypub/followers/foobar',
);
});

describe('updateProfile', () => {
it('can update name', () => {
const account = new Account(
1,
'test-uuid',
'testuser',
'Original Name',
'Original Bio',
new URL('https://example.com/original-avatar.png'),
new URL('https://example.com/original-banner.png'),
null,
new URL('https://example.com/ap_id'),
new URL('https://example.com/url'),
new URL('https://example.com/followers'),
);

account.updateProfile({ name: 'Updated Name' });

expect(account.name).toBe('Updated Name');
expect(account.bio).toBe('Original Bio');
expect(account.avatarUrl?.href).toBe(
'https://example.com/original-avatar.png',
);
expect(account.bannerImageUrl?.href).toBe(
'https://example.com/original-banner.png',
);
});

it('can update bio', () => {
const account = new Account(
1,
'test-uuid',
'testuser',
'Original Name',
'Original Bio',
new URL('https://example.com/original-avatar.png'),
new URL('https://example.com/original-banner.png'),
null,
new URL('https://example.com/ap_id'),
new URL('https://example.com/url'),
new URL('https://example.com/followers'),
);

account.updateProfile({ bio: 'Updated Bio' });

expect(account.name).toBe('Original Name');
expect(account.bio).toBe('Updated Bio');
expect(account.avatarUrl?.href).toBe(
'https://example.com/original-avatar.png',
);
expect(account.bannerImageUrl?.href).toBe(
'https://example.com/original-banner.png',
);
});

it('can update avatarUrl', () => {
const account = new Account(
1,
'test-uuid',
'testuser',
'Original Name',
'Original Bio',
new URL('https://example.com/original-avatar.png'),
new URL('https://example.com/original-banner.png'),
null,
new URL('https://example.com/ap_id'),
new URL('https://example.com/url'),
new URL('https://example.com/followers'),
);

account.updateProfile({
avatarUrl: new URL('https://example.com/updated-avatar.png'),
});

expect(account.name).toBe('Original Name');
expect(account.bio).toBe('Original Bio');
expect(account.avatarUrl?.href).toBe(
'https://example.com/updated-avatar.png',
);
expect(account.bannerImageUrl?.href).toBe(
'https://example.com/original-banner.png',
);
});

it('can update bannerImageUrl', () => {
const account = new Account(
1,
'test-uuid',
'testuser',
'Original Name',
'Original Bio',
new URL('https://example.com/original-avatar.png'),
new URL('https://example.com/original-banner.png'),
null,
new URL('https://example.com/ap_id'),
new URL('https://example.com/url'),
new URL('https://example.com/followers'),
);

account.updateProfile({
bannerImageUrl: new URL(
'https://example.com/updated-banner.png',
),
});

expect(account.name).toBe('Original Name');
expect(account.bio).toBe('Original Bio');
expect(account.avatarUrl?.href).toBe(
'https://example.com/original-avatar.png',
);
expect(account.bannerImageUrl?.href).toBe(
'https://example.com/updated-banner.png',
);
});

it('can update multiple properties at once', () => {
const account = new Account(
1,
'test-uuid',
'testuser',
'Original Name',
'Original Bio',
new URL('https://example.com/original-avatar.png'),
new URL('https://example.com/original-banner.png'),
null,
new URL('https://example.com/ap_id'),
new URL('https://example.com/url'),
new URL('https://example.com/followers'),
);

account.updateProfile({
name: 'Updated Name',
bio: 'Updated Bio',
avatarUrl: new URL('https://example.com/updated-avatar.png'),
bannerImageUrl: new URL(
'https://example.com/updated-banner.png',
),
});

expect(account.name).toBe('Updated Name');
expect(account.bio).toBe('Updated Bio');
expect(account.avatarUrl?.href).toBe(
'https://example.com/updated-avatar.png',
);
expect(account.bannerImageUrl?.href).toBe(
'https://example.com/updated-banner.png',
);
});

it('can set values to null', () => {
const account = new Account(
1,
'test-uuid',
'testuser',
'Original Name',
'Original Bio',
new URL('https://example.com/original-avatar.png'),
new URL('https://example.com/original-banner.png'),
null,
new URL('https://example.com/ap_id'),
new URL('https://example.com/url'),
new URL('https://example.com/followers'),
);

account.updateProfile({
bio: null,
avatarUrl: null,
});

expect(account.name).toBe('Original Name');
expect(account.bio).toBeNull();
expect(account.avatarUrl).toBeNull();
expect(account.bannerImageUrl?.href).toBe(
'https://example.com/original-banner.png',
);
});
});
});
58 changes: 57 additions & 1 deletion src/account/account.repository.knex.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeAll, describe, it } from 'vitest';
import { beforeAll, describe, expect, it, vi } from 'vitest';

import assert from 'node:assert';
import { AsyncEvents } from 'core/events';
Expand All @@ -9,6 +9,7 @@ import { KnexAccountRepository } from '../account/account.repository.knex';
import { AccountService } from '../account/account.service';
import { FedifyContextFactory } from '../activitypub/fedify-context.factory';
import { SiteService } from '../site/site.service';
import { AccountUpdatedEvent } from './account-updated.event';
import { Account } from './account.entity';

describe('KnexAccountRepository', () => {
Expand Down Expand Up @@ -182,4 +183,59 @@ describe('KnexAccountRepository', () => {
assert(result, 'Account should have been found');
assert(result.uuid !== null, 'Account should have a uuid');
});

it('emits AccountUpdatedEvent when an account is saved', async () => {
// Setup
const events = new AsyncEvents();
const accountRepository = new KnexAccountRepository(client, events);
const emitSpy = vi.spyOn(events, 'emitAsync');

// Get an account from the DB to update
const account = await client('accounts').select('*').first();

if (!account) {
throw new Error('No account found for test');
}

// Create an Account entity
const accountEntity = new Account(
account.id,
account.uuid || 'test-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
account.ap_id ? new URL(account.ap_id) : null,
account.url ? new URL(account.url) : null,
account.ap_followers_url ? new URL(account.ap_followers_url) : null,
);

accountEntity.updateProfile({
name: 'Updated Name',
bio: 'Updated Bio',
});

// Act
await accountRepository.save(accountEntity);

// Assert
expect(emitSpy).toHaveBeenCalledWith(
AccountUpdatedEvent.getName(),
expect.any(AccountUpdatedEvent),
);

// Verify that the event contains the account
const event = emitSpy.mock.calls[0][1] as AccountUpdatedEvent;
expect(event.getAccount()).toBe(accountEntity);

// Verify the database was updated
const updatedAccount = await client('accounts')
.where({ id: account.id })
.first();

expect(updatedAccount.name).toBe('Updated Name');
expect(updatedAccount.bio).toBe('Updated Bio');
});
});
23 changes: 23 additions & 0 deletions src/account/account.repository.knex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AsyncEvents } from 'core/events';
import type { Knex } from 'knex';
import { parseURL } from '../core/url';
import type { Site } from '../site/site.service';
import { AccountUpdatedEvent } from './account-updated.event';
import { Account, type AccountSite } from './account.entity';

export class KnexAccountRepository {
Expand All @@ -11,6 +12,28 @@ export class KnexAccountRepository {
private readonly events: AsyncEvents,
) {}

async save(account: Account): Promise<void> {
if (account.isNew) {
throw new Error(
'Saving of new Accounts has not been implemented yet.',
Copy link
Member

Choose a reason for hiding this comment

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

We can rename this method to update and use it only for this purpose, and have a different implementation for save. My thinking is that this method is updating the account and sending update event.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Our repository pattern uses a single save even for both update and saves, this means that callers of the code don't need to know if the entity they have a reference to is new or not.

We could change this if you think it's worth it but I think it needs to be part of a larger discussion and the PostRepository would need to be updated too

Copy link
Member

Choose a reason for hiding this comment

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

In that case, it’s fine. I get that the single save method simplifies things in PostRepository, we can keep the same patter here.

);
}

await this.db('accounts')
.update({
name: account.name,
bio: account.bio,
avatar_url: account.avatarUrl?.href,
banner_image_url: account.bannerImageUrl?.href,
})
.where({ id: account.id });

await this.events.emitAsync(
AccountUpdatedEvent.getName(),
new AccountUpdatedEvent(account),
);
}

async getBySite(site: Site): Promise<Account> {
const users = await this.db('users').where('site_id', site.id);
if (users.length === 0) {
Expand Down
Loading