Skip to content

Commit 01a216d

Browse files
emilioaccclaude
andauthored
feat: add getProfile() to ATXPAccount (#139)
* feat: add getProfile() to ATXPAccount - Add MeResponse type with account profile fields - Cache full /me response in ATXPAccount - Add getProfile() that returns cached profile data - Works whether account_id is in connection string or not Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add dedup guard for concurrent getProfile() calls Mirrors the _accountIdPromise pattern from getAccountId() to prevent duplicate /me requests when getProfile() is called concurrently. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 36069fd commit 01a216d

4 files changed

Lines changed: 148 additions & 3 deletions

File tree

packages/atxp-common/src/atxpAccount.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,109 @@ describe('ATXPAccount', () => {
122122
);
123123
});
124124
});
125+
126+
describe('getProfile', () => {
127+
it('should return cached profile after getAccountId fetches /me', async () => {
128+
const meResponse = {
129+
accountId: 'atxp_acct_test',
130+
accountType: 'agent',
131+
funded: false,
132+
developerMode: false,
133+
stripeConnected: false,
134+
};
135+
const mockFetch = vi.fn().mockResolvedValue({
136+
ok: true,
137+
json: async () => meResponse,
138+
});
139+
140+
const account = new ATXPAccount(
141+
'https://accounts.example.com?connection_token=ct_abc123',
142+
{ fetchFn: mockFetch }
143+
);
144+
145+
// First call triggers /me fetch
146+
const profile = await account.getProfile();
147+
expect(profile).toEqual(meResponse);
148+
expect(mockFetch).toHaveBeenCalledTimes(1);
149+
150+
// Second call returns cached — no extra fetch
151+
const profile2 = await account.getProfile();
152+
expect(profile2).toEqual(meResponse);
153+
expect(mockFetch).toHaveBeenCalledTimes(1);
154+
});
155+
156+
it('should share /me response with getAccountId (no duplicate request)', async () => {
157+
const meResponse = {
158+
accountId: 'atxp_acct_shared',
159+
accountType: 'human',
160+
funded: undefined,
161+
};
162+
const mockFetch = vi.fn().mockResolvedValue({
163+
ok: true,
164+
json: async () => meResponse,
165+
});
166+
167+
const account = new ATXPAccount(
168+
'https://accounts.example.com?connection_token=ct_abc123',
169+
{ fetchFn: mockFetch }
170+
);
171+
172+
// getAccountId fetches /me
173+
const accountId = await account.getAccountId();
174+
expect(accountId).toBe('atxp:atxp_acct_shared');
175+
176+
// getProfile returns cached data — no extra request
177+
const profile = await account.getProfile();
178+
expect(profile.accountType).toBe('human');
179+
expect(mockFetch).toHaveBeenCalledTimes(1);
180+
});
181+
182+
it('should fetch /me even when account_id is in connection string', async () => {
183+
const mockFetch = vi.fn().mockResolvedValue({
184+
ok: true,
185+
json: async () => ({
186+
accountId: 'atxp_acct_inline',
187+
accountType: 'agent',
188+
funded: false,
189+
}),
190+
});
191+
192+
// account_id is in the connection string, so getAccountId() won't call /me
193+
const account = new ATXPAccount(
194+
'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_inline',
195+
{ fetchFn: mockFetch }
196+
);
197+
198+
// getAccountId uses cached value — no fetch
199+
const accountId = await account.getAccountId();
200+
expect(accountId).toBe('atxp:atxp_acct_inline');
201+
expect(mockFetch).not.toHaveBeenCalled();
202+
203+
// getProfile must fetch /me to get full profile
204+
const profile = await account.getProfile();
205+
expect(profile.accountType).toBe('agent');
206+
expect(profile.funded).toBe(false);
207+
expect(mockFetch).toHaveBeenCalledTimes(1);
208+
});
209+
210+
it('should return profile with funded field for agent accounts', async () => {
211+
const mockFetch = vi.fn().mockResolvedValue({
212+
ok: true,
213+
json: async () => ({
214+
accountId: 'atxp_acct_agent',
215+
accountType: 'agent',
216+
funded: true,
217+
}),
218+
});
219+
220+
const account = new ATXPAccount(
221+
'https://accounts.example.com?connection_token=ct_abc123',
222+
{ fetchFn: mockFetch }
223+
);
224+
225+
const profile = await account.getProfile();
226+
expect(profile.accountType).toBe('agent');
227+
expect(profile.funded).toBe(true);
228+
});
229+
});
125230
});

packages/atxp-common/src/atxpAccount.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Account, PaymentMaker } from './types.js';
1+
import type { Account, PaymentMaker, MeResponse } from './types.js';
22
import type { FetchLike, Currency, AccountId, PaymentIdentifier, Destination, Chain, Source } from './types.js';
33
import BigNumber from 'bignumber.js';
44

@@ -147,6 +147,8 @@ export class ATXPAccount implements Account {
147147
private _cachedAccountId: AccountId | null = null;
148148
private _unqualifiedAccountId: string | null = null;
149149
private _accountIdPromise: Promise<AccountId> | null = null;
150+
private _cachedProfile: MeResponse | null = null;
151+
private _profilePromise: Promise<MeResponse> | null = null;
150152

151153
constructor(connectionString: string, opts?: { fetchFn?: FetchLike; }) {
152154
const { origin, token, accountId } = parseConnectionString(connectionString);
@@ -188,6 +190,26 @@ export class ATXPAccount implements Account {
188190
return this._accountIdPromise;
189191
}
190192

193+
/**
194+
* Get the full /me profile, fetching if not already cached.
195+
* If getAccountId() was satisfied from the connection string (no /me call),
196+
* this will make a /me request to get the full profile.
197+
*/
198+
async getProfile(): Promise<MeResponse> {
199+
if (this._cachedProfile) {
200+
return this._cachedProfile;
201+
}
202+
if (!this._profilePromise) {
203+
this._profilePromise = this.fetchAccountIdFromMe().then(() => {
204+
if (!this._cachedProfile) {
205+
throw new Error('ATXPAccount: /me succeeded but profile was not cached');
206+
}
207+
return this._cachedProfile;
208+
});
209+
}
210+
return this._profilePromise;
211+
}
212+
191213
/**
192214
* Fetch account ID from the /me endpoint using Bearer auth
193215
*/
@@ -205,12 +227,13 @@ export class ATXPAccount implements Account {
205227
throw new Error(`ATXPAccount: /me failed: ${response.status} ${response.statusText} ${text}`);
206228
}
207229

208-
const json = await response.json() as { accountId?: string };
230+
const json = await response.json() as MeResponse;
209231
if (!json?.accountId) {
210232
throw new Error('ATXPAccount: /me did not return accountId');
211233
}
212234

213-
// Cache the result
235+
// Cache the full profile and account ID
236+
this._cachedProfile = json;
214237
this._unqualifiedAccountId = json.accountId;
215238
this._cachedAccountId = `atxp:${json.accountId}` as AccountId;
216239
return this._cachedAccountId;

packages/atxp-common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export {
7070
type DestinationMaker,
7171
type PaymentDestination,
7272
type Account,
73+
type MeResponse,
7374
type Source,
7475
extractAddressFromAccountId,
7576
extractNetworkFromAccountId

packages/atxp-common/src/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,22 @@ export type Account = PaymentDestination & {
185185
createSpendPermission: (resourceUrl: string) => Promise<string | null>;
186186
}
187187

188+
/**
189+
* Response from the /me endpoint on the accounts service.
190+
* Contains account identity and status information.
191+
*/
192+
export interface MeResponse {
193+
accountId: string;
194+
accountType: string;
195+
funded?: boolean;
196+
developerMode?: boolean;
197+
stripeConnected?: boolean;
198+
displayName?: string;
199+
email?: string;
200+
ownerEmail?: string;
201+
isOrphan?: boolean;
202+
}
203+
188204
/**
189205
* Extract the address portion from a fully-qualified accountId
190206
* @param accountId - Format: network:address

0 commit comments

Comments
 (0)