Skip to content
Draft
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: 2 additions & 0 deletions packages/ai-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Initial release ([#7693](https://github.com/MetaMask/core/pull/7693))
- Add `AiDigestController` for fetching and caching AI-generated asset digests ([#7746](https://github.com/MetaMask/core/pull/7746))
- Add Market Insights support to `AiDigestController` with `fetchMarketInsights` and `clearMarketInsights` actions ([#7930](https://github.com/MetaMask/core/pull/7930))
- Add `searchDigests` method to `AiDigestService` for calling the GET endpoint (currently mocked) ([#7930](https://github.com/MetaMask/core/pull/7930))

[Unreleased]: https://github.com/MetaMask/core/
233 changes: 221 additions & 12 deletions packages/ai-controllers/src/AiDigestController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
CACHE_DURATION_MS,
MAX_CACHE_ENTRIES,
} from '.';
import type { AiDigestControllerMessenger } from '.';
import type {
AiDigestControllerMessenger,
DigestService,
MarketInsightsReport,
} from '.';

const mockData = {
id: '123e4567-e89b-12d3-a456-426614174000',
Expand All @@ -20,19 +24,65 @@ const mockData = {
updatedAt: '2026-01-21T10:30:00.000Z',
};

const mockMarketInsightsReport: MarketInsightsReport = {
version: '1.0',
asset: 'btc',
generatedAt: '2026-02-11T10:32:52.403Z',
headline: 'BTC Dips 50% to $68K',
summary: 'Bitcoin trades around $68,000 after a sharp correction.',
trends: [
{
title: 'Institutions Buying the Dip',
description: 'Prominent investors are accumulating BTC.',
category: 'macro',
impact: 'positive',
articles: [
{
title: 'Tom Lee says buy the dip',
url: 'https://example.com/article',
source: 'coindesk.com',
date: '2026-02-11',
},
],
tweets: [
{
contentSummary: 'Buy BTC now.',
url: 'https://x.com/status/123',
author: '@testuser',
date: '2026-02-10',
},
],
},
],
sources: [
{ name: 'CoinDesk', url: 'https://www.coindesk.com', type: 'news' },
],
};

const createMessenger = (): AiDigestControllerMessenger => {
return new Messenger({
namespace: 'AiDigestController',
}) as AiDigestControllerMessenger;
};

const createMockService = (
overrides?: Partial<DigestService>,
): DigestService => ({
fetchDigest: jest.fn().mockResolvedValue(mockData),
searchDigests: jest.fn().mockResolvedValue(mockMarketInsightsReport),
...overrides,
});

describe('AiDigestController', () => {
it('returns default state', () => {
expect(getDefaultAiDigestControllerState()).toStrictEqual({ digests: {} });
expect(getDefaultAiDigestControllerState()).toStrictEqual({
digests: {},
marketInsights: {},
});
});

it('fetches and caches a digest', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -46,7 +96,7 @@ describe('AiDigestController', () => {
});

it('returns cached digest on subsequent calls', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -60,7 +110,7 @@ describe('AiDigestController', () => {

it('refetches after cache expires', async () => {
jest.useFakeTimers();
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -75,9 +125,9 @@ describe('AiDigestController', () => {
});

it('throws on fetch errors', async () => {
const mockService = {
const mockService = createMockService({
fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')),
};
});
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -90,7 +140,7 @@ describe('AiDigestController', () => {
});

it('clears a specific digest', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -103,7 +153,7 @@ describe('AiDigestController', () => {
});

it('clears all digests', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -118,7 +168,7 @@ describe('AiDigestController', () => {

it('evicts stale entries on fetch', async () => {
jest.useFakeTimers();
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -134,7 +184,7 @@ describe('AiDigestController', () => {
});

it('evicts oldest entries when exceeding max cache size', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
Expand All @@ -151,7 +201,7 @@ describe('AiDigestController', () => {
});

it('registers action handlers', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const mockService = createMockService();
const messenger = createMessenger();
const controller = new AiDigestController({
messenger,
Expand All @@ -174,4 +224,163 @@ describe('AiDigestController', () => {
expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000);
expect(MAX_CACHE_ENTRIES).toBe(50);
});

// --- Market Insights tests ---

describe('fetchMarketInsights', () => {
it('fetches and caches market insights', async () => {
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

const result = await controller.fetchMarketInsights('eip155:1/slip44:0');

expect(result).toStrictEqual(mockMarketInsightsReport);
expect(
controller.state.marketInsights['eip155:1/slip44:0'],
).toBeDefined();
expect(
controller.state.marketInsights['eip155:1/slip44:0'].data,
).toStrictEqual(mockMarketInsightsReport);
});

it('returns cached market insights on subsequent calls', async () => {
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

await controller.fetchMarketInsights('eip155:1/slip44:0');
await controller.fetchMarketInsights('eip155:1/slip44:0');

expect(mockService.searchDigests).toHaveBeenCalledTimes(1);
});

it('refetches market insights after cache expires', async () => {
jest.useFakeTimers();
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

await controller.fetchMarketInsights('eip155:1/slip44:0');
jest.advanceTimersByTime(CACHE_DURATION_MS + 1);
await controller.fetchMarketInsights('eip155:1/slip44:0');

expect(mockService.searchDigests).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});

it('returns null when no insights exist (404)', async () => {
const mockService = createMockService({
searchDigests: jest.fn().mockResolvedValue(null),
});
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

const result = await controller.fetchMarketInsights('unknown-asset');

expect(result).toBeNull();
expect(controller.state.marketInsights['unknown-asset']).toBeUndefined();
});

it('clears stale cache when service returns null', async () => {
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

// First fetch succeeds
await controller.fetchMarketInsights('eip155:1/slip44:0');
expect(
controller.state.marketInsights['eip155:1/slip44:0'],
).toBeDefined();

// Service now returns null (e.g. insights expired on server)
(mockService.searchDigests as jest.Mock).mockResolvedValue(null);
jest.useFakeTimers();
jest.advanceTimersByTime(CACHE_DURATION_MS + 1);
const result = await controller.fetchMarketInsights('eip155:1/slip44:0');

expect(result).toBeNull();
expect(
controller.state.marketInsights['eip155:1/slip44:0'],
).toBeUndefined();
jest.useRealTimers();
});

it('clears market insights for a specific asset', async () => {
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

await controller.fetchMarketInsights('eip155:1/slip44:0');
controller.clearMarketInsights('eip155:1/slip44:0');

expect(
controller.state.marketInsights['eip155:1/slip44:0'],
).toBeUndefined();
});

it('evicts stale market insights entries on fetch', async () => {
jest.useFakeTimers();
const mockService = createMockService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

await controller.fetchMarketInsights('asset-a');
jest.advanceTimersByTime(CACHE_DURATION_MS + 1);
await controller.fetchMarketInsights('asset-b');

expect(controller.state.marketInsights['asset-a']).toBeUndefined();
expect(controller.state.marketInsights['asset-b']).toBeDefined();
jest.useRealTimers();
});

it('registers fetchMarketInsights messenger action', async () => {
const mockService = createMockService();
const messenger = createMessenger();
const controller = new AiDigestController({
messenger,
digestService: mockService,
});

const result = await messenger.call(
'AiDigestController:fetchMarketInsights',
'eip155:1/slip44:0',
);
expect(result).toStrictEqual(mockMarketInsightsReport);
expect(controller.state.marketInsights).toBeDefined();
});

it('registers clearMarketInsights messenger action', async () => {
const mockService = createMockService();
const messenger = createMessenger();
const controller = new AiDigestController({
messenger,
digestService: mockService,
});

await controller.fetchMarketInsights('eip155:1/slip44:0');
messenger.call(
'AiDigestController:clearMarketInsights',
'eip155:1/slip44:0',
);

expect(
controller.state.marketInsights['eip155:1/slip44:0'],
).toBeUndefined();
});
});
});
Loading
Loading