📄 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