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
105 changes: 105 additions & 0 deletions packages/atxp-common/src/atxpAccount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,109 @@ describe('ATXPAccount', () => {
);
});
});

describe('getProfile', () => {
it('should return cached profile after getAccountId fetches /me', async () => {
const meResponse = {
accountId: 'atxp_acct_test',
accountType: 'agent',
funded: false,
developerMode: false,
stripeConnected: false,
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => meResponse,
});

const account = new ATXPAccount(
'https://accounts.example.com?connection_token=ct_abc123',
{ fetchFn: mockFetch }
);

// First call triggers /me fetch
const profile = await account.getProfile();
expect(profile).toEqual(meResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);

// Second call returns cached — no extra fetch
const profile2 = await account.getProfile();
expect(profile2).toEqual(meResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('should share /me response with getAccountId (no duplicate request)', async () => {
const meResponse = {
accountId: 'atxp_acct_shared',
accountType: 'human',
funded: undefined,
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => meResponse,
});

const account = new ATXPAccount(
'https://accounts.example.com?connection_token=ct_abc123',
{ fetchFn: mockFetch }
);

// getAccountId fetches /me
const accountId = await account.getAccountId();
expect(accountId).toBe('atxp:atxp_acct_shared');

// getProfile returns cached data — no extra request
const profile = await account.getProfile();
expect(profile.accountType).toBe('human');
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('should fetch /me even when account_id is in connection string', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
accountId: 'atxp_acct_inline',
accountType: 'agent',
funded: false,
}),
});

// account_id is in the connection string, so getAccountId() won't call /me
const account = new ATXPAccount(
'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_inline',
{ fetchFn: mockFetch }
);

// getAccountId uses cached value — no fetch
const accountId = await account.getAccountId();
expect(accountId).toBe('atxp:atxp_acct_inline');
expect(mockFetch).not.toHaveBeenCalled();

// getProfile must fetch /me to get full profile
const profile = await account.getProfile();
expect(profile.accountType).toBe('agent');
expect(profile.funded).toBe(false);
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('should return profile with funded field for agent accounts', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
accountId: 'atxp_acct_agent',
accountType: 'agent',
funded: true,
}),
});

const account = new ATXPAccount(
'https://accounts.example.com?connection_token=ct_abc123',
{ fetchFn: mockFetch }
);

const profile = await account.getProfile();
expect(profile.accountType).toBe('agent');
expect(profile.funded).toBe(true);
});
});
});
29 changes: 26 additions & 3 deletions packages/atxp-common/src/atxpAccount.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Account, PaymentMaker } from './types.js';
import type { Account, PaymentMaker, MeResponse } from './types.js';
import type { FetchLike, Currency, AccountId, PaymentIdentifier, Destination, Chain, Source } from './types.js';
import BigNumber from 'bignumber.js';

Expand Down Expand Up @@ -147,6 +147,8 @@ export class ATXPAccount implements Account {
private _cachedAccountId: AccountId | null = null;
private _unqualifiedAccountId: string | null = null;
private _accountIdPromise: Promise<AccountId> | null = null;
private _cachedProfile: MeResponse | null = null;
private _profilePromise: Promise<MeResponse> | null = null;

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

/**
* Get the full /me profile, fetching if not already cached.
* If getAccountId() was satisfied from the connection string (no /me call),
* this will make a /me request to get the full profile.
*/
async getProfile(): Promise<MeResponse> {
if (this._cachedProfile) {
return this._cachedProfile;
}
if (!this._profilePromise) {
this._profilePromise = this.fetchAccountIdFromMe().then(() => {
if (!this._cachedProfile) {
throw new Error('ATXPAccount: /me succeeded but profile was not cached');
}
return this._cachedProfile;
});
}
return this._profilePromise;
}

/**
* Fetch account ID from the /me endpoint using Bearer auth
*/
Expand All @@ -205,12 +227,13 @@ export class ATXPAccount implements Account {
throw new Error(`ATXPAccount: /me failed: ${response.status} ${response.statusText} ${text}`);
}

const json = await response.json() as { accountId?: string };
const json = await response.json() as MeResponse;
if (!json?.accountId) {
throw new Error('ATXPAccount: /me did not return accountId');
}

// Cache the result
// Cache the full profile and account ID
this._cachedProfile = json;
this._unqualifiedAccountId = json.accountId;
this._cachedAccountId = `atxp:${json.accountId}` as AccountId;
return this._cachedAccountId;
Expand Down
1 change: 1 addition & 0 deletions packages/atxp-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export {
type DestinationMaker,
type PaymentDestination,
type Account,
type MeResponse,
type Source,
extractAddressFromAccountId,
extractNetworkFromAccountId
Expand Down
16 changes: 16 additions & 0 deletions packages/atxp-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ export type Account = PaymentDestination & {
createSpendPermission: (resourceUrl: string) => Promise<string | null>;
}

/**
* Response from the /me endpoint on the accounts service.
* Contains account identity and status information.
*/
export interface MeResponse {
accountId: string;
accountType: string;
funded?: boolean;
developerMode?: boolean;
stripeConnected?: boolean;
displayName?: string;
email?: string;
ownerEmail?: string;
isOrphan?: boolean;
}

/**
* Extract the address portion from a fully-qualified accountId
* @param accountId - Format: network:address
Expand Down