Skip to content

Commit 5dfdff8

Browse files
mike182ukvershwal
authored andcommitted
Implemented account profile update (#535)
ref https://linear.app/ghost/issue/AP-1076 Implemented the logic needed for updating an account's profile data via the API
1 parent 6fa87f7 commit 5dfdff8

File tree

10 files changed

+249
-49
lines changed

10 files changed

+249
-49
lines changed

features/account-update.feature

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
Feature: Update account information
2+
Background:
3+
Given we are followed by "Alice"
24

35
Scenario: Update account information
4-
When an authenticated "put" request is made to "/.ghost/activitypub/account" with data:
5-
"""
6-
{
7-
"name": "Updated Name",
8-
"bio": "Updated bio",
9-
"username": "updatedUsername",
10-
"avatarUrl": "https://example.com/avatar.jpg",
11-
"bannerImageUrl": "https://example.com/banner.jpg"
12-
}
13-
"""
6+
Given an authenticated "put" request is made to "/.ghost/activitypub/account" with the data:
7+
| name | Updated Name |
8+
| bio | Updated bio |
9+
| username | updatedUsername |
10+
| avatarUrl | https://example.com/avatar.jpg |
11+
| bannerImageUrl | https://example.com/banner.jpg |
12+
And the request is accepted with a 200
13+
When an authenticated "get" request is made to "/.ghost/activitypub/account/me"
1414
Then the request is accepted with a 200
15+
And the response contains the account details:
16+
| name | Updated Name |
17+
| bio | Updated bio |
18+
| avatarUrl | https://example.com/avatar.jpg |
19+
| bannerImageUrl | https://example.com/banner.jpg |
20+
| handle | @updatedUsername@fake-ghost-activitypub.test |
21+
And a "Update(Us)" activity is sent to "Alice"

features/step_definitions/stepdefs.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,7 @@ When(
683683
);
684684

685685
When(
686-
/^an authenticated (\"(post|put)\"\s)?request is made to "(.*)" with data:$/,
686+
/^an authenticated (\"(post|put)\"\s)?request is made to "(.*)" with the data:$/,
687687
async function (method, path, data) {
688688
this.response = await fetchActivityPub(
689689
`http://fake-ghost-activitypub.test${path}`,
@@ -693,7 +693,7 @@ When(
693693
Accept: 'application/ld+json',
694694
'Content-Type': 'application/json',
695695
},
696-
body: data,
696+
body: JSON.stringify(data.rowsHash()),
697697
},
698698
);
699699
},
@@ -1511,7 +1511,6 @@ Then('the request is accepted', async function () {
15111511
});
15121512

15131513
Then('the request is accepted with a {int}', function (statusCode) {
1514-
assert(this.response.ok);
15151514
assert.equal(
15161515
this.response.status,
15171516
statusCode,
@@ -1650,7 +1649,8 @@ Then(
16501649
}
16511650
const actor = this.actors[actorName];
16521651

1653-
const object = this.objects[objectNameOrType];
1652+
const object =
1653+
this.objects[objectNameOrType] || this.actors[objectNameOrType];
16541654

16551655
const inboxUrl = new URL(actor.inbox);
16561656

@@ -1991,3 +1991,15 @@ Then('the response contains {string} account details', async function (name) {
19911991
assert.equal(typeof responseJson.followedByMe, 'boolean');
19921992
assert.equal(typeof responseJson.followsMe, 'boolean');
19931993
});
1994+
1995+
Then('the response contains the account details:', async function (data) {
1996+
const responseJson = await this.response.clone().json();
1997+
1998+
for (const [key, value] of Object.entries(data.rowsHash())) {
1999+
assert.equal(
2000+
responseJson[key],
2001+
value,
2002+
`Expected ${key} to be "${value}" but got "${responseJson[key]}"`,
2003+
);
2004+
}
2005+
});

src/account/account.entity.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type AccountSite = {
2525
export interface ProfileUpdateParams {
2626
name?: string | null;
2727
bio?: string | null;
28+
username?: string;
2829
avatarUrl?: URL | null;
2930
bannerImageUrl?: URL | null;
3031
}
@@ -37,13 +38,14 @@ export class Account extends BaseEntity {
3738

3839
private _name: string | null;
3940
private _bio: string | null;
41+
private _username: string;
4042
private _avatarUrl: URL | null;
4143
private _bannerImageUrl: URL | null;
4244

4345
constructor(
4446
public readonly id: number | null,
4547
uuid: string | null,
46-
public readonly username: string,
48+
username: string,
4749
name: string | null,
4850
bio: string | null,
4951
avatarUrl: URL | null,
@@ -57,6 +59,7 @@ export class Account extends BaseEntity {
5759

5860
this._name = name;
5961
this._bio = bio;
62+
this._username = username;
6063
this._avatarUrl = avatarUrl;
6164
this._bannerImageUrl = bannerImageUrl;
6265

@@ -90,6 +93,10 @@ export class Account extends BaseEntity {
9093
return this._bio;
9194
}
9295

96+
get username(): string {
97+
return this._username;
98+
}
99+
93100
get avatarUrl(): URL | null {
94101
return this._avatarUrl;
95102
}
@@ -107,6 +114,10 @@ export class Account extends BaseEntity {
107114
this._bio = params.bio;
108115
}
109116

117+
if (params.username !== undefined) {
118+
this._username = params.username;
119+
}
120+
110121
if (params.avatarUrl !== undefined) {
111122
this._avatarUrl = params.avatarUrl;
112123
}

src/account/account.entity.unit.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ describe('Account', () => {
8888

8989
expect(account.name).toBe('Updated Name');
9090
expect(account.bio).toBe('Original Bio');
91+
expect(account.username).toBe('testuser');
9192
expect(account.avatarUrl?.href).toBe(
9293
'https://example.com/original-avatar.png',
9394
);
@@ -115,6 +116,35 @@ describe('Account', () => {
115116

116117
expect(account.name).toBe('Original Name');
117118
expect(account.bio).toBe('Updated Bio');
119+
expect(account.username).toBe('testuser');
120+
expect(account.avatarUrl?.href).toBe(
121+
'https://example.com/original-avatar.png',
122+
);
123+
expect(account.bannerImageUrl?.href).toBe(
124+
'https://example.com/original-banner.png',
125+
);
126+
});
127+
128+
it('can update username', () => {
129+
const account = new Account(
130+
1,
131+
'test-uuid',
132+
'testuser',
133+
'Original Name',
134+
'Original Bio',
135+
new URL('https://example.com/original-avatar.png'),
136+
new URL('https://example.com/original-banner.png'),
137+
null,
138+
new URL('https://example.com/ap_id'),
139+
new URL('https://example.com/url'),
140+
new URL('https://example.com/followers'),
141+
);
142+
143+
account.updateProfile({ username: 'updatedtestuser' });
144+
145+
expect(account.name).toBe('Original Name');
146+
expect(account.bio).toBe('Original Bio');
147+
expect(account.username).toBe('updatedtestuser');
118148
expect(account.avatarUrl?.href).toBe(
119149
'https://example.com/original-avatar.png',
120150
);
@@ -144,6 +174,7 @@ describe('Account', () => {
144174

145175
expect(account.name).toBe('Original Name');
146176
expect(account.bio).toBe('Original Bio');
177+
expect(account.username).toBe('testuser');
147178
expect(account.avatarUrl?.href).toBe(
148179
'https://example.com/updated-avatar.png',
149180
);
@@ -175,6 +206,7 @@ describe('Account', () => {
175206

176207
expect(account.name).toBe('Original Name');
177208
expect(account.bio).toBe('Original Bio');
209+
expect(account.username).toBe('testuser');
178210
expect(account.avatarUrl?.href).toBe(
179211
'https://example.com/original-avatar.png',
180212
);
@@ -201,6 +233,7 @@ describe('Account', () => {
201233
account.updateProfile({
202234
name: 'Updated Name',
203235
bio: 'Updated Bio',
236+
username: 'updatedtestuser',
204237
avatarUrl: new URL('https://example.com/updated-avatar.png'),
205238
bannerImageUrl: new URL(
206239
'https://example.com/updated-banner.png',
@@ -209,6 +242,7 @@ describe('Account', () => {
209242

210243
expect(account.name).toBe('Updated Name');
211244
expect(account.bio).toBe('Updated Bio');
245+
expect(account.username).toBe('updatedtestuser');
212246
expect(account.avatarUrl?.href).toBe(
213247
'https://example.com/updated-avatar.png',
214248
);
@@ -239,6 +273,7 @@ describe('Account', () => {
239273

240274
expect(account.name).toBe('Original Name');
241275
expect(account.bio).toBeNull();
276+
expect(account.username).toBe('testuser');
242277
expect(account.avatarUrl).toBeNull();
243278
expect(account.bannerImageUrl?.href).toBe(
244279
'https://example.com/original-banner.png',

src/account/account.repository.knex.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class KnexAccountRepository {
2323
.update({
2424
name: account.name,
2525
bio: account.bio,
26+
username: account.username,
2627
avatar_url: account.avatarUrl?.href,
2728
banner_image_url: account.bannerImageUrl?.href,
2829
})

src/account/account.service.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,41 @@ export class AccountService {
509509

510510
return newAccount;
511511
}
512+
513+
async updateAccountProfile(
514+
account: Account,
515+
data: {
516+
name: string;
517+
bio: string;
518+
username: string;
519+
avatarUrl: string;
520+
bannerImageUrl: string;
521+
},
522+
) {
523+
const profileData = {
524+
name: data.name,
525+
bio: data.bio,
526+
username: data.username,
527+
avatarUrl: data.avatarUrl ? new URL(data.avatarUrl) : null,
528+
bannerImageUrl: data.bannerImageUrl
529+
? new URL(data.bannerImageUrl)
530+
: null,
531+
};
532+
533+
if (
534+
account.name === profileData.name &&
535+
account.bio === profileData.bio &&
536+
account.username === profileData.username &&
537+
account.avatarUrl?.toString() ===
538+
profileData.avatarUrl?.toString() &&
539+
account.bannerImageUrl?.toString() ===
540+
profileData.bannerImageUrl?.toString()
541+
) {
542+
return;
543+
}
544+
545+
account.updateProfile(profileData);
546+
547+
await this.accountRepository.save(account);
548+
}
512549
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import type { FedifyContextFactory } from 'activitypub/fedify-context.factory';
4+
import type { AsyncEvents } from 'core/events';
5+
import type { Knex } from 'knex';
6+
import type { Account } from './account.entity';
7+
import type { KnexAccountRepository } from './account.repository.knex';
8+
import { AccountService } from './account.service';
9+
10+
describe('AccountService', () => {
11+
let knex: Knex;
12+
let asyncEvents: AsyncEvents;
13+
let knexAccountRepository: KnexAccountRepository;
14+
let fedifyContextFactory: FedifyContextFactory;
15+
let generateKeyPair: () => Promise<CryptoKeyPair>;
16+
let accountService: AccountService;
17+
18+
beforeEach(() => {
19+
knex = {} as Knex;
20+
asyncEvents = {} as AsyncEvents;
21+
knexAccountRepository = {
22+
save: vi.fn(),
23+
} as unknown as KnexAccountRepository;
24+
fedifyContextFactory = {} as FedifyContextFactory;
25+
generateKeyPair = vi.fn();
26+
27+
accountService = new AccountService(
28+
knex,
29+
asyncEvents,
30+
knexAccountRepository,
31+
fedifyContextFactory,
32+
generateKeyPair,
33+
);
34+
});
35+
36+
describe('updateAccountProfile', () => {
37+
it('should update the account profile with the provided data', async () => {
38+
const account = {
39+
updateProfile: vi.fn(),
40+
} as unknown as Account;
41+
const data = {
42+
name: 'Alice',
43+
bio: 'Eiusmod in cillum elit sit cupidatat reprehenderit ad quis qui consequat officia elit.',
44+
username: 'alice',
45+
avatarUrl: 'https://example.com/avatar/alice.png',
46+
bannerImageUrl: 'https://example.com/banner/alice.png',
47+
};
48+
49+
await accountService.updateAccountProfile(account, data);
50+
51+
expect(account.updateProfile).toHaveBeenCalledWith({
52+
name: data.name,
53+
bio: data.bio,
54+
username: data.username,
55+
avatarUrl: new URL(data.avatarUrl),
56+
bannerImageUrl: new URL(data.bannerImageUrl),
57+
});
58+
59+
expect(knexAccountRepository.save).toHaveBeenCalledWith(account);
60+
});
61+
62+
it('should do nothing if the provided data is the same as the existing account profile', async () => {
63+
const data = {
64+
name: 'Alice',
65+
bio: 'Eiusmod in cillum elit sit cupidatat reprehenderit ad quis qui consequat officia elit.',
66+
username: 'alice',
67+
avatarUrl: 'https://example.com/avatar/alice.png',
68+
bannerImageUrl: 'https://example.com/banner/alice.png',
69+
};
70+
71+
const account = {
72+
name: data.name,
73+
bio: data.bio,
74+
username: data.username,
75+
avatarUrl: new URL(data.avatarUrl),
76+
bannerImageUrl: new URL(data.bannerImageUrl),
77+
updateProfile: vi.fn(),
78+
} as unknown as Account;
79+
80+
await accountService.updateAccountProfile(account, {
81+
name: data.name,
82+
bio: data.bio,
83+
username: data.username,
84+
avatarUrl: data.avatarUrl,
85+
bannerImageUrl: data.bannerImageUrl,
86+
});
87+
88+
expect(knexAccountRepository.save).not.toHaveBeenCalled();
89+
});
90+
91+
it('should handle empty values for avatarUrl and bannerImageUrl', async () => {
92+
const account = {
93+
updateProfile: vi.fn(),
94+
} as unknown as Account;
95+
96+
await accountService.updateAccountProfile(account, {
97+
name: 'Alice',
98+
bio: 'Eiusmod in cillum elit sit cupidatat reprehenderit ad quis qui consequat officia elit.',
99+
username: 'alice',
100+
avatarUrl: '',
101+
bannerImageUrl: '',
102+
});
103+
104+
expect(account.updateProfile).toHaveBeenCalledWith({
105+
name: 'Alice',
106+
bio: 'Eiusmod in cillum elit sit cupidatat reprehenderit ad quis qui consequat officia elit.',
107+
username: 'alice',
108+
avatarUrl: null,
109+
bannerImageUrl: null,
110+
});
111+
});
112+
});
113+
});

0 commit comments

Comments
 (0)