jevstrudel.git / tools / deploy / resources.test.mjs
1import { readFileSync } from 'node:fs';
2import { describe, expect, it } from 'vitest';
3import { bucketNames, kvTitle, migrate, parseList, provision } from './resources.mjs';
4
5const production = () => JSON.parse(readFileSync(new URL('../../worker/wrangler.json', import.meta.url), 'utf8'));
6
7// A wrangler that knows `account`'s databases and namespaces, and records
8// every call.
9function fakeWrangler(account) {
10  const calls = [];
11  const wrangler = (args) => {
12    calls.push(args.join(' '));
13    const [a, b, c, d] = args;
14    if (a === 'd1' && b === 'list') return `⛅️ wrangler\n${JSON.stringify(account.d1, null, 2)}\n`;
15    if (a === 'd1' && b === 'create') return void account.d1.push({ name: c, uuid: `uuid-${c}` });
16    if (a === 'kv' && c === 'list') return JSON.stringify(account.kv);
17    if (a === 'kv' && c === 'create') return void account.kv.push({ title: d, id: `id-${d}` });
18    if (a === 'r2' && c === 'list') {
19      return `Listing buckets...\n${(account.r2 ?? []).map((n) => `name:           ${n}\ncreation_date:  2026-09-25T00:00:00.000Z`).join('\n\n')}\n`;
20    }
21    if (a === 'r2' && c === 'create') return void (account.r2 ??= []).push(d);
22    return '';
23  };
24  return { wrangler, calls };
25}
26
27describe('provision: the deploy finds or creates its storage, by name', () => {
28  it('writes the ids of what the account already has, creating nothing', () => {
29    const { wrangler, calls } = fakeWrangler({
30      d1: [{ name: 'other', uuid: 'u0' }, { name: 'jevstrudel', uuid: 'u1' }],
31      kv: [
32        { title: 'jevstrudel-cache', id: 'k1' },
33        { title: 'jevstrudel-oauth-kv', id: 'k2' },
34      ],
35      r2: ['other', 'jevstrudel-covers'],
36    });
37    const config = provision(production(), wrangler, () => {});
38    expect(config.d1_databases.find((d) => d.binding === 'DB').database_id).toBe('u1');
39    expect(Object.fromEntries(config.kv_namespaces.map((ns) => [ns.binding, ns.id]))).toEqual({ CACHE: 'k1', OAUTH_KV: 'k2' });
40    expect(calls.some((c) => c.includes('create'))).toBe(false);
41  });
42
43  it('creates what is missing, once, and binds the created ids', () => {
44    const account = { d1: [], kv: [] };
45    const { wrangler, calls } = fakeWrangler(account);
46    const config = provision(production(), wrangler, () => {});
47    expect(config.d1_databases[0].database_id).toBe('uuid-jevstrudel');
48    expect(calls.filter((c) => c.startsWith('d1 create'))).toEqual(['d1 create jevstrudel --update-config=false']);
49    for (const ns of config.kv_namespaces ?? []) {
50      expect(ns.id).toBe(`id-${kvTitle('jevstrudel', ns.binding)}`);
51    }
52    // a second deploy finds them all
53    const again = fakeWrangler(account);
54    provision(production(), again.wrangler, () => {});
55    expect(again.calls.some((c) => c.includes('create'))).toBe(false);
56  });
57
58  it('leaves a binding that already names its resource alone', () => {
59    const config = production();
60    config.d1_databases[0].database_id = 'pinned';
61    const { wrangler, calls } = fakeWrangler({ d1: [], kv: [] });
62    provision({ ...config, kv_namespaces: [], r2_buckets: [] }, wrangler, () => {});
63    expect(config.d1_databases[0].database_id).toBe('pinned');
64    expect(calls).toEqual([]);
65  });
66
67  it('makes an R2 bucket the account lacks, by the name the binding gives, once', () => {
68    const account = { d1: [{ name: 'jevstrudel', uuid: 'u1' }], kv: [{ title: 'jevstrudel-cache', id: 'k1' }], r2: [] };
69    const { wrangler, calls } = fakeWrangler(account);
70    const config = provision(production(), wrangler, () => {});
71    expect(config.r2_buckets).toEqual([{ binding: 'COVERS', bucket_name: 'jevstrudel-covers' }]);
72    expect(calls.filter((c) => c.startsWith('r2 bucket create'))).toEqual([
73      'r2 bucket create jevstrudel-covers --update-config=false',
74    ]);
75    const again = fakeWrangler(account);
76    provision(production(), again.wrangler, () => {});
77    expect(again.calls.some((c) => c.includes('create'))).toBe(false);
78  });
79
80  it("reads wrangler's bucket list, which has no JSON", () => {
81    const listed = 'Listing buckets...\nname:           a-b\ncreation_date:  x\n\nname:           c\ncreation_date:  y\n';
82    expect(bucketNames(listed)).toEqual(['a-b', 'c']);
83    expect(bucketNames('Listing buckets...\n')).toEqual([]);
84  });
85
86  it("titles a KV namespace as wrangler's own provisioning does", () => {
87    expect(kvTitle('jevstrudel', 'CACHE')).toBe('jevstrudel-cache');
88    expect(kvTitle('jevstrudel', 'SESSION_KEYS')).toBe('jevstrudel-session-keys');
89  });
90
91  it('reads a list past a banner, and refuses output with none', () => {
92    expect(parseList('⛅️ wrangler 4\n[WARNING] proxy\n[\n  {"a": 1}\n]', 'x')).toEqual([{ a: 1 }]);
93    expect(parseList('[]', 'x')).toEqual([]);
94    expect(() => parseList('[WARNING] no list', 'x')).toThrow(/no JSON list/);
95  });
96});
97
98describe('migrate: remote migrations, for every database the Worker binds', () => {
99  it('applies each database’s migrations to production', () => {
100    const { wrangler, calls } = fakeWrangler({ d1: [], kv: [] });
101    migrate(production(), wrangler);
102    expect(calls).toEqual(['d1 migrations apply jevstrudel --remote --env=']);
103  });
104});