📄 src/web3/session/projects.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach } from 'vitest';
2
3const mocks = vi.hoisted(() => ({
4  getReadClient: vi.fn(),
5  getItem: vi.fn(),
6  setItem: vi.fn(),
7}));
8
9vi.mock('../client/registry', () => ({ getReadClient: mocks.getReadClient }));
10vi.mock('./storage', () => ({ getItem: mocks.getItem, setItem: mocks.setItem }));
11
12import { projectLocale, loadProject, loadProjects } from './projects';
13import type { Project, ProjectVersion } from '../client/types';
14
15function version(overrides: Partial<ProjectVersion> = {}): ProjectVersion {
16  return {
17    uri: 'ar://x', publicationDate: new Date(0), name: 'n', headline: 'h',
18    descriptionMarkdown: '', arweaveAddressLogo: '', arweaveAddressBanner: '',
19    minimumFundingBeforeStartWork: 0, maximumFunding: 0, tags: [], status: 0, complexity: 0,
20    ...overrides,
21  };
22}
23
24function project(overrides: Partial<Project> = {}): Project {
25  return {
26    id: 1,
27    balance: 5_000_000_000_000_000_000, // 5 * 10^18
28    availableLanguages: ['fra', 'eng'],
29    versions: [version({ name: 'fr' }), version({ name: 'en' })],
30    ...overrides,
31  };
32}
33
34beforeEach(() => {
35  Object.values(mocks).forEach((m) => m.mockReset());
36});
37
38describe('projectLocale', () => {
39  it('returns the version matching the requested language when available', () => {
40    const result = projectLocale(project(), 'eng', 1116);
41    expect(result.lang).toBe('eng');
42    expect(result.version.name).toBe('en');
43  });
44
45  it('computes balance using the chain\'s decimals', () => {
46    const result = projectLocale(project({ balance: 2_500_000_000_000_000_000 }), 'eng', 1116);
47    expect(result.balance).toBe(2.5);
48  });
49
50  it('falls back to English using the ProjectManager contract\'s " eng" (leading-space) bytes4 encoding', () => {
51    // ProjectManager stores every language as a raw bytes4 value with a
52    // leading-space + 3-letter code (" eng", " fra", " esp", ...) — space +
53    // 3 chars = exactly 4 bytes, avoiding null-byte padding. See
54    // test/ProjectManager.v2.spec.js:171-173,201. This is a different, older
55    // convention than the unpadded 'eng'/'fra' used by models_v2/
56    // patchContext.ts/orm.ts elsewhere in the codebase, not a typo — and since
57    // it's a single on-chain field, every entry in availableLanguages for a
58    // ProjectManager-sourced project uses this same space-padded form.
59    const p = project({ availableLanguages: [' fra', ' eng'], versions: [version({ name: 'fr' }), version({ name: 'en' })] });
60    const result = projectLocale(p, ' deu', 1116); // ' deu' not available; English IS available
61
62    expect(result.lang).toBe(' eng');
63    expect(result.version.name).toBe('en');
64  });
65
66  it('falls back to the first available language when the requested language is absent and no English fallback matches', () => {
67    const p = project({ availableLanguages: ['jpn', 'kor'], versions: [version({ name: 'jp' }), version({ name: 'kr' })] });
68    const result = projectLocale(p, 'deu', 1116);
69
70    expect(result.lang).toBe('jpn');
71    expect(result.version.name).toBe('jp');
72  });
73
74  it('throws when the project has no available languages at all', () => {
75    const p = project({ availableLanguages: [], versions: [] });
76    expect(() => projectLocale(p, 'eng', 1116)).toThrow('No language available for project 1');
77  });
78
79  it('preserves the project id in the returned locale', () => {
80    const result = projectLocale(project({ id: 42 }), 'eng', 1116);
81    expect(result.id).toBe(42);
82  });
83});
84
85describe('loadProject', () => {
86  it('returns the cached project without hitting the chain', async () => {
87    const cached = { id: 1, lang: 'eng', version: version(), balance: 1 };
88    mocks.getItem.mockReturnValue(cached);
89
90    const result = await loadProject(1116, 'eng', 1);
91
92    expect(result).toBe(cached);
93    expect(mocks.getReadClient).not.toHaveBeenCalled();
94  });
95
96  it('fetches from chain and caches on a cache miss', async () => {
97    mocks.getItem.mockReturnValue(null);
98    const client = {
99      ready: Promise.resolve(),
100      projectManagerStorage: { loadProject: vi.fn().mockResolvedValue(project({ id: 7 })) },
101    };
102    mocks.getReadClient.mockReturnValue(client);
103
104    const result = await loadProject(1116, 'eng', 7);
105
106    expect(result.id).toBe(7);
107    expect(mocks.setItem).toHaveBeenCalledWith('1116_eng_7_v2', expect.objectContaining({ id: 7 }));
108  });
109
110  it('bypasses the cache when refresh is true even if a cached value exists', async () => {
111    mocks.getItem.mockReturnValue({ id: 1, lang: 'eng', version: version(), balance: 999 });
112    const client = {
113      ready: Promise.resolve(),
114      projectManagerStorage: { loadProject: vi.fn().mockResolvedValue(project({ id: 1, balance: 0 })) },
115    };
116    mocks.getReadClient.mockReturnValue(client);
117
118    const result = await loadProject(1116, 'eng', 1, true);
119
120    expect(result.balance).toBe(0);
121  });
122});
123
124describe('loadProjects', () => {
125  it('falls back to cached projects when no read client is available for the chain', async () => {
126    const errorSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
127    mocks.getReadClient.mockImplementation(() => { throw new Error('No read client for chain 9999'); });
128    mocks.getItem.mockImplementation((key: string) => {
129      if (key === '9999_eng_nbProjects_v2') return '2';
130      if (key === '9999_eng_1_v2') return { id: 1, lang: 'eng', version: version(), balance: 1 };
131      if (key === '9999_eng_2_v2') return { id: 2, lang: 'eng', version: version(), balance: 2 };
132      return null;
133    });
134
135    const result = await loadProjects(9999, 'eng');
136
137    expect(result).toHaveLength(2);
138    expect(result.map((p) => p?.id)).toEqual([1, 2]);
139    errorSpy.mockRestore();
140  });
141
142  it('preserves id-by-position alignment in the offline cache fallback, with null for a cache gap (fixed)', async () => {
143    // Regression test: getCachedProjects() must return the same shape as the
144    // online path — array length === nbProjects, null at any index whose
145    // entry is missing — so result[i] always corresponds to project (i+1)
146    // regardless of whether the data came from a live fetch or this fallback.
147    vi.spyOn(console, 'warn').mockImplementation(() => {});
148    mocks.getReadClient.mockImplementation(() => { throw new Error('offline'); });
149    mocks.getItem.mockImplementation((key: string) => {
150      if (key === '9999_eng_nbProjects_v2') return '3';
151      if (key === '9999_eng_1_v2') return { id: 1, lang: 'eng', version: version(), balance: 1 };
152      // project 2 missing from cache
153      if (key === '9999_eng_3_v2') return { id: 3, lang: 'eng', version: version(), balance: 3 };
154      return null;
155    });
156
157    const result = await loadProjects(9999, 'eng');
158
159    expect(result).toEqual([
160      { id: 1, lang: 'eng', version: version(), balance: 1 },
161      null,
162      { id: 3, lang: 'eng', version: version(), balance: 3 },
163    ]);
164    expect(result[1]).toBeNull();
165    expect(result[2]?.id).toBe(3);
166  });
167
168  it('updates the stored project count when the on-chain count differs from the cached count', async () => {
169    mocks.getItem.mockImplementation((key: string) => (key.endsWith('nbProjects_v2') ? '1' : null));
170    const client = {
171      ready: Promise.resolve(),
172      projectManagerStorage: {
173        nbProjects: vi.fn().mockResolvedValue(2),
174        loadProject: vi.fn().mockResolvedValue(project({ id: 1 })),
175      },
176    };
177    mocks.getReadClient.mockReturnValue(client);
178
179    await loadProjects(1116, 'eng');
180
181    expect(mocks.setItem).toHaveBeenCalledWith('1116_eng_nbProjects_v2', '2');
182  });
183
184  it('does not rewrite the stored project count when it matches the on-chain count', async () => {
185    mocks.getItem.mockImplementation((key: string) => (key.endsWith('nbProjects_v2') ? '2' : null));
186    const client = {
187      ready: Promise.resolve(),
188      projectManagerStorage: {
189        nbProjects: vi.fn().mockResolvedValue(2),
190        loadProject: vi.fn().mockResolvedValue(project({ id: 1 })),
191      },
192    };
193    mocks.getReadClient.mockReturnValue(client);
194
195    await loadProjects(1116, 'eng');
196
197    expect(mocks.setItem).not.toHaveBeenCalledWith('1116_eng_nbProjects_v2', expect.anything());
198  });
199
200  it('returns null (not a rejection) at the position of a project that fails to load, preserving array length', async () => {
201    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
202    mocks.getItem.mockReturnValue(null);
203    const client = {
204      ready: Promise.resolve(),
205      projectManagerStorage: {
206        nbProjects: vi.fn().mockResolvedValue(2),
207        loadProject: vi.fn()
208          .mockResolvedValueOnce(project({ id: 1 }))
209          .mockRejectedValueOnce(new Error('rpc fail')),
210      },
211    };
212    mocks.getReadClient.mockReturnValue(client);
213
214    const result = await loadProjects(1116, 'eng');
215
216    expect(result).toHaveLength(2);
217    expect(result[0]?.id).toBe(1);
218    expect(result[1]).toBeNull();
219    errorSpy.mockRestore();
220  });
221
222  it('returns an empty array when the chain reports zero projects', async () => {
223    mocks.getItem.mockReturnValue(null);
224    const client = {
225      ready: Promise.resolve(),
226      projectManagerStorage: { nbProjects: vi.fn().mockResolvedValue(0) },
227    };
228    mocks.getReadClient.mockReturnValue(client);
229
230    const result = await loadProjects(1116, 'eng');
231
232    expect(result).toEqual([]);
233  });
234});
235