aboutsummaryrefslogtreecommitdiffstats
path: root/packages/e2e_tests/tests/api/backups.test.ts
blob: 51c16c5e59a319a68371743a935c75b09179a36f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
import AdmZip from "adm-zip";
import { beforeEach, describe, expect, inject, it } from "vitest";

import { createKarakeepClient } from "@karakeep/sdk";

import { createTestUser } from "../../utils/api";

describe("Backups API", () => {
  const port = inject("karakeepPort");

  if (!port) {
    throw new Error("Missing required environment variables");
  }

  let client: ReturnType<typeof createKarakeepClient>;
  let apiKey: string;

  beforeEach(async () => {
    apiKey = await createTestUser();
    client = createKarakeepClient({
      baseUrl: `http://localhost:${port}/api/v1/`,
      headers: {
        "Content-Type": "application/json",
        authorization: `Bearer ${apiKey}`,
      },
    });
  });

  it("should list backups", async () => {
    const { data: backupsData, response } = await client.GET("/backups");

    expect(response.status).toBe(200);
    expect(backupsData).toBeDefined();
    expect(backupsData!.backups).toBeDefined();
    expect(Array.isArray(backupsData!.backups)).toBe(true);
  });

  it("should trigger a backup and return the backup record", async () => {
    const { data: backup, response } = await client.POST("/backups");

    expect(response.status).toBe(201);
    expect(backup).toBeDefined();
    expect(backup!.id).toBeDefined();
    expect(backup!.userId).toBeDefined();
    expect(backup!.assetId).toBeDefined();
    expect(backup!.status).toBe("pending");
    expect(backup!.size).toBe(0);
    expect(backup!.bookmarkCount).toBe(0);

    // Verify the backup appears in the list
    const { data: backupsData } = await client.GET("/backups");
    expect(backupsData).toBeDefined();
    expect(backupsData!.backups).toBeDefined();
    expect(backupsData!.backups.some((b) => b.id === backup!.id)).toBe(true);
  });

  it("should get and delete a backup", async () => {
    // First trigger a backup
    const { data: createdBackup } = await client.POST("/backups");
    expect(createdBackup).toBeDefined();

    const backupId = createdBackup!.id;

    // Get the specific backup
    const { data: backup, response: getResponse } = await client.GET(
      "/backups/{backupId}",
      {
        params: {
          path: {
            backupId,
          },
        },
      },
    );

    expect(getResponse.status).toBe(200);
    expect(backup).toBeDefined();
    expect(backup!.id).toBe(backupId);
    expect(backup!.userId).toBeDefined();
    expect(backup!.assetId).toBeDefined();
    expect(backup!.status).toBe("pending");

    // Delete the backup
    const { response: deleteResponse } = await client.DELETE(
      "/backups/{backupId}",
      {
        params: {
          path: {
            backupId,
          },
        },
      },
    );

    expect(deleteResponse.status).toBe(204);

    // Verify it's deleted
    const { response: getDeletedResponse } = await client.GET(
      "/backups/{backupId}",
      {
        params: {
          path: {
            backupId,
          },
        },
      },
    );

    expect(getDeletedResponse.status).toBe(404);
  });

  it("should return 404 for non-existent backup", async () => {
    const { response } = await client.GET("/backups/{backupId}", {
      params: {
        path: {
          backupId: "non-existent-backup-id",
        },
      },
    });

    expect(response.status).toBe(404);
  });

  it("should return 404 when deleting non-existent backup", async () => {
    const { response } = await client.DELETE("/backups/{backupId}", {
      params: {
        path: {
          backupId: "non-existent-backup-id",
        },
      },
    });

    expect(response.status).toBe(404);
  });

  it("should handle multiple backups", async () => {
    // Trigger multiple backups
    const { data: backup1 } = await client.POST("/backups");
    const { data: backup2 } = await client.POST("/backups");

    expect(backup1).toBeDefined();
    expect(backup2).toBeDefined();
    expect(backup1!.id).not.toBe(backup2!.id);

    // Get all backups
    const { data: backupsData, response } = await client.GET("/backups");

    expect(response.status).toBe(200);
    expect(backupsData).toBeDefined();
    expect(backupsData!.backups).toBeDefined();
    expect(Array.isArray(backupsData!.backups)).toBe(true);
    expect(backupsData!.backups.length).toBeGreaterThanOrEqual(2);
    expect(backupsData!.backups.some((b) => b.id === backup1!.id)).toBe(true);
    expect(backupsData!.backups.some((b) => b.id === backup2!.id)).toBe(true);
  });

  it("should validate full backup lifecycle", async () => {
    // Step 1: Create some test bookmarks
    const bookmarks = [];
    for (let i = 0; i < 3; i++) {
      const { data: bookmark } = await client.POST("/bookmarks", {
        body: {
          type: "text",
          title: `Test Bookmark ${i + 1}`,
          text: `This is test bookmark number ${i + 1}`,
        },
      });
      expect(bookmark).toBeDefined();
      bookmarks.push(bookmark!);
    }

    // Step 2: Trigger a backup
    const { data: createdBackup, response: createResponse } =
      await client.POST("/backups");

    expect(createResponse.status).toBe(201);
    expect(createdBackup).toBeDefined();
    expect(createdBackup!.id).toBeDefined();
    expect(createdBackup!.status).toBe("pending");
    expect(createdBackup!.bookmarkCount).toBe(0);
    expect(createdBackup!.size).toBe(0);

    const backupId = createdBackup!.id;

    // Step 3: Poll until backup is completed or failed
    let backup;
    let attempts = 0;
    const maxAttempts = 60; // Wait up to 60 seconds
    const pollInterval = 1000; // Poll every second

    while (attempts < maxAttempts) {
      const { data: currentBackup } = await client.GET("/backups/{backupId}", {
        params: {
          path: {
            backupId,
          },
        },
      });

      backup = currentBackup;

      if (backup!.status === "success" || backup!.status === "failure") {
        break;
      }

      await new Promise((resolve) => setTimeout(resolve, pollInterval));
      attempts++;
    }

    // Step 4: Verify backup completed successfully
    expect(backup).toBeDefined();
    expect(backup!.status).toBe("success");
    expect(backup!.bookmarkCount).toBeGreaterThanOrEqual(3);
    expect(backup!.size).toBeGreaterThan(0);
    expect(backup!.errorMessage).toBeNull();

    // Step 5: Download the backup
    const downloadResponse = await fetch(
      `http://localhost:${port}/api/v1/backups/${backupId}/download`,
      {
        headers: {
          authorization: `Bearer ${apiKey}`,
        },
      },
    );

    expect(downloadResponse.status).toBe(200);
    expect(downloadResponse.headers.get("content-type")).toContain(
      "application/zip",
    );

    const backupBlob = await downloadResponse.blob();
    expect(backupBlob.size).toBeGreaterThan(0);
    expect(backupBlob.size).toBe(backup!.size);

    // Step 6: Unzip and validate the backup contents
    const arrayBuffer = await backupBlob.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    // Verify it's a valid ZIP file (starts with PK signature)
    expect(buffer[0]).toBe(0x50); // 'P'
    expect(buffer[1]).toBe(0x4b); // 'K'

    // Unzip the backup file
    const zip = new AdmZip(buffer);
    const zipEntries = zip.getEntries();

    // Should contain exactly one JSON file
    expect(zipEntries.length).toBe(1);
    const jsonEntry = zipEntries[0];
    expect(jsonEntry.entryName).toMatch(/^karakeep-backup-.*\.json$/);

    // Extract and parse the JSON content
    const jsonContent = jsonEntry.getData().toString("utf8");
    const backupData = JSON.parse(jsonContent);

    // Validate the backup structure
    expect(backupData).toBeDefined();
    expect(backupData.bookmarks).toBeDefined();
    expect(Array.isArray(backupData.bookmarks)).toBe(true);
    expect(backupData.bookmarks.length).toBeGreaterThanOrEqual(3);

    // Validate that our test bookmarks are in the backup
    const backupTitles = backupData.bookmarks.map(
      (b: { title: string }) => b.title,
    );
    expect(backupTitles).toContain("Test Bookmark 1");
    expect(backupTitles).toContain("Test Bookmark 2");
    expect(backupTitles).toContain("Test Bookmark 3");

    // Validate bookmark structure
    const firstBookmark = backupData.bookmarks[0];
    expect(firstBookmark).toHaveProperty("content");
    expect(firstBookmark.content).toHaveProperty("type");

    // Step 7: Verify the backup appears in the list with updated status
    const { data: backupsData } = await client.GET("/backups");
    const listedBackup = backupsData!.backups.find((b) => b.id === backupId);

    expect(listedBackup).toBeDefined();
    expect(listedBackup!.status).toBe("success");
    expect(listedBackup!.bookmarkCount).toBe(backup!.bookmarkCount);
    expect(listedBackup!.size).toBe(backup!.size);
  });
});