github-issue-tower-defence-management 1.42.2 → 1.42.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,56 @@
1
1
  import { GoogleSpreadsheetRepository } from './GoogleSpreadsheetRepository';
2
- import { describe, test, expect } from '@jest/globals';
2
+ import { describe, test, expect, jest, beforeEach } from '@jest/globals';
3
3
  import { LocalStorageRepository } from './LocalStorageRepository';
4
4
 
5
+ type SpreadsheetGetResponse = {
6
+ status: number;
7
+ data: {
8
+ sheets?: Array<{
9
+ properties?: { title?: string | null } | null;
10
+ }> | null;
11
+ };
12
+ };
13
+
14
+ type ValuesGetResponse = {
15
+ status: number;
16
+ data: { values?: unknown[][] | null };
17
+ };
18
+
19
+ type SimpleResponse = { status: number; data: unknown };
20
+
5
21
  describe('GoogleSpreadsheetRepository', () => {
6
22
  const localStorageRepository = new LocalStorageRepository();
7
- const repository = new GoogleSpreadsheetRepository(localStorageRepository);
8
23
  const spreadsheetUrl =
9
24
  'https://docs.google.com/spreadsheets/d/1N_3y0y46v5tHbra5YSm6PldflcsF1bkfeWDdQ3MRuXM/edit?gid=0#gid=0';
10
25
 
26
+ const mockSpreadsheetsGet = jest.fn<() => Promise<SpreadsheetGetResponse>>();
27
+ const mockSpreadsheetsValuesGet = jest.fn<() => Promise<ValuesGetResponse>>();
28
+ const mockSpreadsheetsValuesUpdate = jest.fn<() => Promise<SimpleResponse>>();
29
+ const mockSpreadsheetsValuesAppend = jest.fn<() => Promise<SimpleResponse>>();
30
+ const mockSpreadsheetsBatchUpdate = jest.fn<() => Promise<SimpleResponse>>();
31
+
32
+ const mockSheetsClient = {
33
+ spreadsheets: {
34
+ get: mockSpreadsheetsGet,
35
+ values: {
36
+ get: mockSpreadsheetsValuesGet,
37
+ update: mockSpreadsheetsValuesUpdate,
38
+ append: mockSpreadsheetsValuesAppend,
39
+ },
40
+ batchUpdate: mockSpreadsheetsBatchUpdate,
41
+ },
42
+ };
43
+
44
+ const repository = new GoogleSpreadsheetRepository(
45
+ localStorageRepository,
46
+ 'dummy-service-account-key',
47
+ () => mockSheetsClient,
48
+ );
49
+
50
+ beforeEach(() => {
51
+ jest.clearAllMocks();
52
+ });
53
+
11
54
  describe('getSpreadsheetId', () => {
12
55
  const testCases: [string, string][] = [
13
56
  [
@@ -29,96 +72,255 @@ describe('GoogleSpreadsheetRepository', () => {
29
72
  });
30
73
 
31
74
  describe('getSheet', () => {
32
- const testCases: [string, string, string[][] | null][] = [
33
- ['SheetUndefined', 'Undefined Sheet', null],
34
- // ['SheetEmpty', 'Empty Sheet', []],
35
- ['SheetSingleCell', 'Single Cell', [['test']]],
36
- [
37
- 'SheetMultipleRows',
38
- 'Multiple Rows',
39
- [
40
- ['1', '2'],
41
- ['3', '4'],
42
- ],
43
- ],
44
- ];
75
+ test('returns null when sheet is not in spreadsheet', async () => {
76
+ mockSpreadsheetsGet.mockResolvedValue({
77
+ status: 200,
78
+ data: { sheets: [] },
79
+ });
45
80
 
46
- test.each(testCases)(
47
- 'gets sheet %s with %s',
48
- async (sheetName: string, _: string, expected: string[][] | null) => {
49
- const result = await repository.getSheet(spreadsheetUrl, sheetName);
50
- expect(result).toEqual(expected);
51
- },
52
- );
53
-
54
- test('returns null for non-existent sheet', async () => {
55
81
  const result = await repository.getSheet(
56
82
  spreadsheetUrl,
57
83
  'NonExistentSheet',
58
84
  );
85
+
86
+ expect(result).toBeNull();
87
+ });
88
+
89
+ test('returns null when sheet name does not match', async () => {
90
+ mockSpreadsheetsGet.mockResolvedValue({
91
+ status: 200,
92
+ data: {
93
+ sheets: [{ properties: { title: 'OtherSheet' } }],
94
+ },
95
+ });
96
+
97
+ const result = await repository.getSheet(
98
+ spreadsheetUrl,
99
+ 'SheetUndefined',
100
+ );
101
+
102
+ expect(result).toBeNull();
103
+ });
104
+
105
+ test('returns single cell value', async () => {
106
+ mockSpreadsheetsGet.mockResolvedValue({
107
+ status: 200,
108
+ data: {
109
+ sheets: [{ properties: { title: 'SheetSingleCell' } }],
110
+ },
111
+ });
112
+ mockSpreadsheetsValuesGet.mockResolvedValue({
113
+ status: 200,
114
+ data: { values: [['test']] },
115
+ });
116
+
117
+ const result = await repository.getSheet(
118
+ spreadsheetUrl,
119
+ 'SheetSingleCell',
120
+ );
121
+
122
+ expect(result).toEqual([['test']]);
123
+ });
124
+
125
+ test('returns multiple rows', async () => {
126
+ mockSpreadsheetsGet.mockResolvedValue({
127
+ status: 200,
128
+ data: {
129
+ sheets: [{ properties: { title: 'SheetMultipleRows' } }],
130
+ },
131
+ });
132
+ mockSpreadsheetsValuesGet.mockResolvedValue({
133
+ status: 200,
134
+ data: {
135
+ values: [
136
+ ['1', '2'],
137
+ ['3', '4'],
138
+ ],
139
+ },
140
+ });
141
+
142
+ const result = await repository.getSheet(
143
+ spreadsheetUrl,
144
+ 'SheetMultipleRows',
145
+ );
146
+
147
+ expect(result).toEqual([
148
+ ['1', '2'],
149
+ ['3', '4'],
150
+ ]);
151
+ });
152
+
153
+ test('returns null when sheet has no values', async () => {
154
+ mockSpreadsheetsGet.mockResolvedValue({
155
+ status: 200,
156
+ data: {
157
+ sheets: [{ properties: { title: 'EmptySheet' } }],
158
+ },
159
+ });
160
+ mockSpreadsheetsValuesGet.mockResolvedValue({
161
+ status: 200,
162
+ data: {},
163
+ });
164
+
165
+ const result = await repository.getSheet(spreadsheetUrl, 'EmptySheet');
166
+
59
167
  expect(result).toBeNull();
60
168
  });
169
+
170
+ test('converts non-string cell values to strings', async () => {
171
+ mockSpreadsheetsGet.mockResolvedValue({
172
+ status: 200,
173
+ data: {
174
+ sheets: [{ properties: { title: 'MixedTypes' } }],
175
+ },
176
+ });
177
+ mockSpreadsheetsValuesGet.mockResolvedValue({
178
+ status: 200,
179
+ data: { values: [[1, true, 'text']] },
180
+ });
181
+
182
+ const result = await repository.getSheet(spreadsheetUrl, 'MixedTypes');
183
+
184
+ expect(result).toEqual([['1', 'true', 'text']]);
185
+ });
61
186
  });
62
187
 
63
188
  describe('updateCell', () => {
64
- const testCases: [string, number, number, string][] = [
65
- ['Sheet1', 0, 0, 'First Value'],
66
- ['Sheet1', 0, 0, 'Updated Value'],
67
- ['Sheet1', 1, 1, '123'],
68
- ['Sheet1', 2, 2, 'Test'],
189
+ beforeEach(() => {
190
+ mockSpreadsheetsGet.mockResolvedValue({
191
+ status: 200,
192
+ data: {
193
+ sheets: [{ properties: { title: 'Sheet1' } }],
194
+ },
195
+ });
196
+ mockSpreadsheetsValuesGet.mockResolvedValue({
197
+ status: 200,
198
+ data: { values: [['existing']] },
199
+ });
200
+ mockSpreadsheetsValuesUpdate.mockResolvedValue({
201
+ status: 200,
202
+ data: {},
203
+ });
204
+ });
205
+
206
+ const testCases: [string, number, number, string, string][] = [
207
+ ['Sheet1', 0, 0, 'First Value', 'Sheet1!A1'],
208
+ ['Sheet1', 0, 0, 'Updated Value', 'Sheet1!A1'],
209
+ ['Sheet1', 1, 1, '123', 'Sheet1!B2'],
210
+ ['Sheet1', 2, 2, 'Test', 'Sheet1!C3'],
69
211
  ];
70
212
 
71
213
  test.each(testCases)(
72
- 'updates cell in sheet %s at row %d col %d with value %s',
73
- async (sheetName: string, row: number, col: number, value: string) => {
214
+ 'updates cell in sheet %s at row %d col %d with value %s using range %s',
215
+ async (sheetName, row, col, value, expectedRange) => {
74
216
  await repository.updateCell(spreadsheetUrl, sheetName, row, col, value);
75
- const result = await repository.getSheet(spreadsheetUrl, sheetName);
76
- if (!result) {
77
- throw new Error('Sheet not found');
78
- }
79
- expect(result[row][col]).toBe(value);
217
+
218
+ expect(mockSpreadsheetsValuesUpdate).toHaveBeenCalledWith(
219
+ expect.objectContaining({
220
+ range: expectedRange,
221
+ valueInputOption: 'RAW',
222
+ requestBody: { values: [[value]] },
223
+ }),
224
+ );
80
225
  },
81
226
  );
82
227
  });
83
228
 
84
229
  describe('appendSheetValues', () => {
85
- const testCases: [string[][]][] = [
86
- [[['Single Row']]],
87
- [[['Multiple', 'Columns']]],
88
- [
89
- [
90
- ['Row1Col1', 'Row1Col2'],
91
- ['Row2Col1', 'Row2Col2'],
92
- ],
93
- ],
94
- ];
230
+ test('appends to existing sheet starting after last row', async () => {
231
+ mockSpreadsheetsGet.mockResolvedValue({
232
+ status: 200,
233
+ data: {
234
+ sheets: [{ properties: { title: 'AppendTest' } }],
235
+ },
236
+ });
237
+ mockSpreadsheetsValuesGet.mockResolvedValue({
238
+ status: 200,
239
+ data: { values: [['Row1'], ['Row2']] },
240
+ });
241
+ mockSpreadsheetsValuesAppend.mockResolvedValue({
242
+ status: 200,
243
+ data: {},
244
+ });
95
245
 
96
- test.each(testCases)(
97
- 'appends values %j to sheet',
98
- async (values: string[][]) => {
99
- const sheetName = 'AppendTest';
100
- const initialSheet = await repository.getSheet(
101
- spreadsheetUrl,
102
- sheetName,
103
- );
104
- const initialLength = initialSheet ? initialSheet.length : 0;
246
+ await repository.appendSheetValues(spreadsheetUrl, 'AppendTest', [
247
+ ['NewRow'],
248
+ ]);
105
249
 
106
- await repository.appendSheetValues(spreadsheetUrl, sheetName, values);
250
+ expect(mockSpreadsheetsValuesAppend).toHaveBeenCalledWith(
251
+ expect.objectContaining({
252
+ range: 'AppendTest!A3:A',
253
+ valueInputOption: 'RAW',
254
+ requestBody: { values: [['NewRow']] },
255
+ }),
256
+ );
257
+ });
107
258
 
108
- const updatedSheet = await repository.getSheet(
109
- spreadsheetUrl,
110
- sheetName,
111
- );
112
- expect(updatedSheet).not.toBeNull();
113
- if (!updatedSheet) {
114
- throw new Error('Sheet not found');
115
- }
116
- expect(updatedSheet.length).toBe(initialLength + values.length);
117
-
118
- for (let i = 0; i < values.length; i++) {
119
- expect(updatedSheet[initialLength + i]).toEqual(values[i]);
120
- }
121
- },
122
- );
259
+ test('appends multiple rows with correct values', async () => {
260
+ mockSpreadsheetsGet.mockResolvedValue({
261
+ status: 200,
262
+ data: {
263
+ sheets: [{ properties: { title: 'AppendTest' } }],
264
+ },
265
+ });
266
+ mockSpreadsheetsValuesGet.mockResolvedValue({
267
+ status: 200,
268
+ data: { values: [['Existing']] },
269
+ });
270
+ mockSpreadsheetsValuesAppend.mockResolvedValue({
271
+ status: 200,
272
+ data: {},
273
+ });
274
+
275
+ const newValues = [
276
+ ['Row1Col1', 'Row1Col2'],
277
+ ['Row2Col1', 'Row2Col2'],
278
+ ];
279
+ await repository.appendSheetValues(
280
+ spreadsheetUrl,
281
+ 'AppendTest',
282
+ newValues,
283
+ );
284
+
285
+ expect(mockSpreadsheetsValuesAppend).toHaveBeenCalledWith(
286
+ expect.objectContaining({
287
+ requestBody: { values: newValues },
288
+ }),
289
+ );
290
+ });
291
+
292
+ test('creates new sheet via batchUpdate when sheet does not exist', async () => {
293
+ mockSpreadsheetsGet.mockResolvedValue({
294
+ status: 200,
295
+ data: { sheets: [] },
296
+ });
297
+ mockSpreadsheetsBatchUpdate.mockResolvedValue({
298
+ status: 200,
299
+ data: {},
300
+ });
301
+ mockSpreadsheetsValuesGet.mockResolvedValue({
302
+ status: 200,
303
+ data: {},
304
+ });
305
+ mockSpreadsheetsValuesAppend.mockResolvedValue({
306
+ status: 200,
307
+ data: {},
308
+ });
309
+
310
+ await repository.appendSheetValues(spreadsheetUrl, 'NewSheet', [['Row']]);
311
+
312
+ expect(mockSpreadsheetsBatchUpdate).toHaveBeenCalledWith(
313
+ expect.objectContaining({
314
+ requestBody: {
315
+ requests: [{ addSheet: { properties: { title: 'NewSheet' } } }],
316
+ },
317
+ }),
318
+ );
319
+ expect(mockSpreadsheetsValuesAppend).toHaveBeenCalledWith(
320
+ expect.objectContaining({
321
+ range: 'NewSheet!A1:A',
322
+ }),
323
+ );
324
+ });
123
325
  });
124
326
  });
@@ -4,15 +4,93 @@ import { LocalStorageRepository } from './LocalStorageRepository';
4
4
  import dotenv from 'dotenv';
5
5
  dotenv.config();
6
6
 
7
+ interface SheetsApiClient {
8
+ spreadsheets: {
9
+ get(params: { spreadsheetId: string }): Promise<{
10
+ status: number;
11
+ data: {
12
+ sheets?: Array<{
13
+ properties?: { title?: string | null } | null;
14
+ }> | null;
15
+ };
16
+ }>;
17
+ values: {
18
+ get(params: { spreadsheetId: string; range: string }): Promise<{
19
+ status: number;
20
+ data: { values?: unknown[][] | null };
21
+ }>;
22
+ update(params: {
23
+ spreadsheetId: string;
24
+ range: string;
25
+ valueInputOption: string;
26
+ requestBody: { values: string[][] };
27
+ }): Promise<{ status: number; data: unknown }>;
28
+ append(params: {
29
+ spreadsheetId: string;
30
+ range: string;
31
+ valueInputOption: string;
32
+ requestBody: { values: string[][] };
33
+ }): Promise<{ status: number; data: unknown }>;
34
+ };
35
+ batchUpdate(params: {
36
+ spreadsheetId: string;
37
+ requestBody: {
38
+ requests: Array<{ addSheet?: { properties?: { title?: string } } }>;
39
+ };
40
+ }): Promise<{ status: number; data: unknown }>;
41
+ };
42
+ }
43
+
7
44
  export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
8
45
  keyFile = './tmp/service-account-key.json';
46
+ private readonly sheetsClient: SheetsApiClient;
9
47
 
10
48
  constructor(
11
49
  readonly localStorageRepository: LocalStorageRepository,
12
50
  serviceAccountKey: string = process.env.GOOGLE_SERVICE_ACCOUNT_KEY ||
13
51
  'dummy',
52
+ sheetsClientFactory?: () => SheetsApiClient,
14
53
  ) {
15
54
  this.localStorageRepository.write(this.keyFile, serviceAccountKey);
55
+ this.sheetsClient = sheetsClientFactory
56
+ ? sheetsClientFactory()
57
+ : (() => {
58
+ const auth = new google.auth.GoogleAuth({
59
+ keyFile: this.keyFile,
60
+ scopes: ['https://www.googleapis.com/auth/spreadsheets'],
61
+ });
62
+ const googleSheets = google.sheets({ version: 'v4', auth });
63
+ return {
64
+ spreadsheets: {
65
+ get: (params: { spreadsheetId: string }) =>
66
+ googleSheets.spreadsheets.get(params),
67
+ values: {
68
+ get: (params: { spreadsheetId: string; range: string }) =>
69
+ googleSheets.spreadsheets.values.get(params),
70
+ update: (params: {
71
+ spreadsheetId: string;
72
+ range: string;
73
+ valueInputOption: string;
74
+ requestBody: { values: string[][] };
75
+ }) => googleSheets.spreadsheets.values.update(params),
76
+ append: (params: {
77
+ spreadsheetId: string;
78
+ range: string;
79
+ valueInputOption: string;
80
+ requestBody: { values: string[][] };
81
+ }) => googleSheets.spreadsheets.values.append(params),
82
+ },
83
+ batchUpdate: (params: {
84
+ spreadsheetId: string;
85
+ requestBody: {
86
+ requests: Array<{
87
+ addSheet?: { properties?: { title?: string } };
88
+ }>;
89
+ };
90
+ }) => googleSheets.spreadsheets.batchUpdate(params),
91
+ },
92
+ };
93
+ })();
16
94
  }
17
95
 
18
96
  getSpreadsheetId = (spreadsheetUrl: string): string => {
@@ -23,11 +101,7 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
23
101
  spreadsheetUrl: string,
24
102
  sheetName: string,
25
103
  ): Promise<string[][] | null> => {
26
- const auth = new google.auth.GoogleAuth({
27
- keyFile: this.keyFile,
28
- scopes: ['https://www.googleapis.com/auth/spreadsheets'],
29
- });
30
- const sheets = google.sheets({ version: 'v4', auth });
104
+ const sheets = this.sheetsClient;
31
105
  const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
32
106
  const responseSheet = await sheets.spreadsheets.get({
33
107
  spreadsheetId,
@@ -64,11 +138,7 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
64
138
  column: number,
65
139
  value: string,
66
140
  ): Promise<void> => {
67
- const auth = new google.auth.GoogleAuth({
68
- keyFile: this.keyFile,
69
- scopes: ['https://www.googleapis.com/auth/spreadsheets'],
70
- });
71
- const sheets = google.sheets({ version: 'v4', auth });
141
+ const sheets = this.sheetsClient;
72
142
  const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
73
143
  await this.createNewSheetIfNotExists(spreadsheetUrl, sheetName);
74
144
  const response = await sheets.spreadsheets.values.update({
@@ -89,11 +159,7 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
89
159
  spreadsheetUrl: string,
90
160
  sheetName: string,
91
161
  ): Promise<void> => {
92
- const auth = new google.auth.GoogleAuth({
93
- keyFile: this.keyFile,
94
- scopes: ['https://www.googleapis.com/auth/spreadsheets'],
95
- });
96
- const sheets = google.sheets({ version: 'v4', auth });
162
+ const sheets = this.sheetsClient;
97
163
  const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
98
164
  const sheet = await this.getSheet(spreadsheetUrl, sheetName);
99
165
  if (sheet !== null) {
@@ -125,11 +191,7 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
125
191
  sheetName: string,
126
192
  values: string[][],
127
193
  ): Promise<void> => {
128
- const auth = new google.auth.GoogleAuth({
129
- keyFile: this.keyFile,
130
- scopes: ['https://www.googleapis.com/auth/spreadsheets'],
131
- });
132
- const sheets = google.sheets({ version: 'v4', auth });
194
+ const sheets = this.sheetsClient;
133
195
  const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
134
196
  await this.createNewSheetIfNotExists(spreadsheetUrl, sheetName);
135
197
  const sheet = await this.getSheet(spreadsheetUrl, sheetName);