libs/neocities-sync.test.lua
1#!/usr/bin/env luajit
2-- neocities-sync.test.lua -- offline tests for the adaptive deploy brain.
3-- Run: luajit libs/neocities-sync.test.lua
4-- No network: a mock op() simulates the server's status codes so we can prove the
5-- control loop reacts correctly (halve on too-big, wait on 429, grow on success,
6-- stop on fatal) and that the diff/batching are right.
7
8package.path = arg[0]:gsub("[^/]+%.lua$", "") .. "?.lua;" .. package.path
9local S = require("neocities-sync")
10
11local pass, fail = 0, 0
12local function check(label, cond)
13 if cond then pass = pass + 1; print(" ok - " .. label)
14 else fail = fail + 1; print(" FAIL - " .. label) end
15end
16
17-- {{{ diff_delete: stale = remote files not built locally; dirs skipped
18do
19 local remote = {
20 { path = "sd/a.html", is_directory = false },
21 { path = "sd/old.html", is_directory = false }, -- stale
22 { path = "sd/sub", is_directory = true }, -- dir, never deleted
23 }
24 local localset = { ["sd/a.html"] = true }
25 local stale = S.diff_delete(remote, localset)
26 check("diff_delete finds only the stale file", #stale == 1 and stale[1] == "sd/old.html")
27end
28-- }}}
29
30-- {{{ diff_upload: only hash-mismatched/missing, largest first
31do
32 local items = {
33 { remote = "a", bytes = 10, sha1 = "x" }, -- matches remote -> skip
34 { remote = "b", bytes = 50, sha1 = "new" }, -- differs -> upload
35 { remote = "c", bytes = 99, sha1 = "z" }, -- missing remote -> upload
36 }
37 local remote_by_path = { a = "x", b = "old" }
38 local need = S.diff_upload(items, remote_by_path)
39 check("diff_upload selects only changed/missing", #need == 2)
40 check("diff_upload orders largest-first", need[1].remote == "c" and need[2].remote == "b")
41end
42-- }}}
43
44-- {{{ take_batch: respects cost budget and count cap, always >=1
45do
46 local items = {}
47 for i = 1, 10 do items[i] = { cost = 1 } end
48 local b1 = S.take_batch(items, 1, 4, 100) -- budget 4 -> 4 items
49 check("take_batch honors budget", #b1 == 4)
50 local b2 = S.take_batch(items, 1, 100, 3) -- count cap 3
51 check("take_batch honors max_count", #b2 == 3)
52 local big = { { cost = 999 } }
53 local b3 = S.take_batch(big, 1, 10, 100) -- single oversized item still taken
54 check("take_batch always takes >=1", #b3 == 1)
55end
56-- }}}
57
58-- {{{ classify: status -> reaction
59do
60 check("200+body = ok", S._classify(200, true) == "ok")
61 check("429 = throttle", S._classify(429, false) == "throttle")
62 check("413 = too_big", S._classify(413, false) == "too_big")
63 check("503 = too_big", S._classify(503, false) == "too_big")
64 check("200+HTML body = too_big", S._classify(200, false) == "too_big")
65 check("403 = fatal", S._classify(403, false) == "fatal")
66end
67-- }}}
68
69-- {{{ run_adaptive: halves the batch until the server accepts it, then finishes
70do
71 local items = {}
72 for i = 1, 10 do items[i] = { cost = 1, id = i } end
73 -- server rejects any batch larger than 2 files (a "too big" 413)
74 local op = function(batch)
75 if #batch > 2 then return { status = 413 } end
76 return { status = 200, ok_body = true }
77 end
78 local stats = S.run_adaptive(items, op, {
79 budget = 8, max_count = 100, floor = 1, grow_after = 1e9, -- no growth this test
80 sleep = function() end,
81 })
82 check("adaptive completes every item", stats and stats.done == 10)
83 check("adaptive shrank at least twice (8->4->2)", stats and stats.shrinks >= 2)
84end
85-- }}}
86
87-- {{{ run_adaptive: 429 -> waits and retries, does NOT shrink
88do
89 local items = { { cost = 1 }, { cost = 1 } }
90 local calls, slept = 0, 0
91 local op = function(batch)
92 calls = calls + 1
93 if calls == 1 then return { status = 429, retry_after = 3 } end
94 return { status = 200, ok_body = true }
95 end
96 local stats = S.run_adaptive(items, op, {
97 budget = 8, grow_after = 1e9,
98 sleep = function(s) slept = slept + s end,
99 })
100 check("throttle then success completes", stats and stats.done == 2)
101 check("throttle slept for Retry-After", slept >= 3)
102 check("throttle did NOT shrink the budget", stats and stats.shrinks == 0)
103end
104-- }}}
105
106-- {{{ run_adaptive: steady success grows the budget
107do
108 local items = {}
109 for i = 1, 30 do items[i] = { cost = 1 } end
110 local op = function() return { status = 200, ok_body = true } end
111 local stats = S.run_adaptive(items, op, {
112 budget = 2, max_count = 100, grow = 2, grow_after = 2, sleep = function() end,
113 })
114 check("steady run completes", stats and stats.done == 30)
115 check("steady run grew the budget", stats and stats.grows >= 1 and stats.final_budget > 2)
116end
117-- }}}
118
119-- {{{ run_adaptive: on_batch_ok fires with batches that cover every item
120do
121 local items = {}
122 for i = 1, 10 do items[i] = { cost = 1 } end
123 local seen = 0
124 local stats = S.run_adaptive(items, function() return { status = 200, ok_body = true } end, {
125 budget = 3, max_count = 100, grow_after = 1e9, sleep = function() end,
126 on_batch_ok = function(batch) seen = seen + #batch end,
127 })
128 check("on_batch_ok covered all items exactly once", stats and seen == 10)
129end
130-- }}}
131
132-- {{{ run_adaptive: fatal status stops with an error
133do
134 local items = { { cost = 1 } }
135 local op = function() return { status = 403 } end
136 local stats, err = S.run_adaptive(items, op, { sleep = function() end })
137 check("fatal returns nil + error", stats == nil and type(err) == "string")
138end
139-- }}}
140
141-- {{{ run_adaptive: persistent failure at floor surfaces an error (no infinite shrink)
142do
143 local items = { { cost = 1, remote = "stubborn" } }
144 local op = function() return { status = 500 } end -- always fails, even at 1 file
145 local stats, err = S.run_adaptive(items, op, { budget = 1, floor = 1, sleep = function() end })
146 check("floor failure surfaces (no wedge)", stats == nil and err:match("minimum batch") ~= nil)
147end
148-- }}}
149
150print("")
151print(string.format("Result: %d passed, %d failed", pass, fail))
152os.exit(fail == 0 and 0 or 1)
153