Files
fpc-cron/tests/test_runner.pas

855 lines
22 KiB
ObjectPascal

{ test_runner -- SQLite-backed end-to-end scheduler tests.
Each scenario gets its own temp DB so failures don't pollute
later runs. Timing-sensitive scenarios use 1-second intervals
and ~3-second sleeps -- the scheduler wakes once a second so a
3-second window catches >= 2 firings reliably. }
program test_runner;
{$mode objfpc}{$H+}
uses
{$IFDEF UNIX}cthreads,{$ENDIF}
Classes, SysUtils, fpjson, syncobjs, DateUtils,
log.types,
database.types, database.dialect, database.schema, database.pool,
cron.types, cron.events, cron.runner;
var
Total, Passed, Failed: Integer;
procedure Check(const Name: string; OK: Boolean; const Detail: string = '');
begin
Inc(Total);
if OK then
begin
Inc(Passed);
Writeln(' [PASS] ', Name);
end
else
begin
Inc(Failed);
if Detail <> '' then
Writeln(' [FAIL] ', Name, ' -- ', Detail)
else
Writeln(' [FAIL] ', Name);
end;
end;
function MakeTempDB(const Tag: string): string;
begin
Result := '/tmp/fpcsched_' + Tag + '_' +
IntToStr(GetProcessID) + '_' +
IntToStr(Random(99999)) + '.sqlite3';
if FileExists(Result) then DeleteFile(Result);
end;
function MakePool(const APath: string): TDBPool;
begin
Result := TDBPool.Create;
Result.Init(dbSQLite, APath);
end;
{ Bootstrap the schema independently of TCron.Create so tests
can seed rows before constructing a scheduler. }
procedure EnsureSchedulerSchema(APool: TDBPool);
begin
APool.DeclareTable(BuildSystemSchedulerSpec(APool.Dialect.NowExpr));
APool.DeclareTable(BuildSchedulerLogSpec);
end;
{ ---- Test recorders ---- }
type
TRecorder = class
public
Lock: TCriticalSection;
PluginCalls: TStringList;
SystemCalls: TStringList;
Events: TStringList;
FailNext: Boolean;
constructor Create;
destructor Destroy; override;
procedure HandlePluginTask(const APluginName, ATaskName: string);
procedure HandleSystemTask(const ATaskName: string);
procedure HandleSystemTaskFailing(const ATaskName: string);
procedure HandleTaskStart(const APluginName, ATaskName: string);
procedure HandleTaskComplete(const APluginName, ATaskName: string;
ASuccess: Boolean; ADurationMs: Integer;
const AError: string);
procedure HandleLog(Level: TLogLevel; const Category, Msg: string);
end;
constructor TRecorder.Create;
begin
inherited Create;
Lock := TCriticalSection.Create;
PluginCalls := TStringList.Create;
SystemCalls := TStringList.Create;
Events := TStringList.Create;
end;
destructor TRecorder.Destroy;
begin
PluginCalls.Free;
SystemCalls.Free;
Events.Free;
Lock.Free;
inherited Destroy;
end;
procedure TRecorder.HandlePluginTask(const APluginName, ATaskName: string);
begin
Lock.Enter;
try
PluginCalls.Add(APluginName + '/' + ATaskName);
finally
Lock.Leave;
end;
end;
procedure TRecorder.HandleSystemTask(const ATaskName: string);
begin
Lock.Enter;
try
SystemCalls.Add(ATaskName);
finally
Lock.Leave;
end;
end;
procedure TRecorder.HandleSystemTaskFailing(const ATaskName: string);
begin
Lock.Enter;
try
SystemCalls.Add(ATaskName);
finally
Lock.Leave;
end;
raise Exception.Create('synthetic system task failure');
end;
procedure TRecorder.HandleTaskStart(const APluginName, ATaskName: string);
begin
Lock.Enter;
try
Events.Add('start:' + APluginName + '/' + ATaskName);
finally
Lock.Leave;
end;
end;
procedure TRecorder.HandleTaskComplete(const APluginName, ATaskName: string;
ASuccess: Boolean; ADurationMs: Integer; const AError: string);
var
S: string;
begin
if ASuccess then S := 'ok' else S := 'err';
Lock.Enter;
try
Events.Add('complete:' + APluginName + '/' + ATaskName + '=' + S);
finally
Lock.Leave;
end;
end;
procedure TRecorder.HandleLog(Level: TLogLevel; const Category, Msg: string);
begin
{ Tests don't assert on log output but the handler is wired up
so the runner exercises its log path during a test run. }
end;
{ ---- helpers ---- }
procedure InsertTaskRow(Pool: TDBPool; const PluginName, TaskName: string;
IntervalSec: Integer; const Category: string;
Enabled: Boolean; FireImmediately: Boolean);
var
Params: TJSONObject;
En: Integer;
begin
if Enabled then En := 1 else En := 0;
Params := TJSONObject.Create;
try
Params.Add('pn', PluginName);
Params.Add('tn', TaskName);
Params.Add('iv', IntervalSec);
Params.Add('cat', Category);
Params.Add('en', En);
if FireImmediately then
Params.Add('nr', FormatDateTime('yyyy-mm-dd hh:nn:ss',
LocalTimeToUniversal(Now) - 1))
else
Params.Add('nr', FormatDateTime('yyyy-mm-dd hh:nn:ss',
LocalTimeToUniversal(Now) + 0.001));
Pool.ExecSQL(
'INSERT INTO system_scheduler ' +
'(task_name, plugin_name, description, category, ' +
' schedule_type, interval_seconds, enabled, next_run) VALUES ' +
'(:tn, :pn, '''', :cat, ''interval'', :iv, :en, :nr)',
Params);
finally
Params.Free;
end;
end;
function CountSchedulerLogRows(Pool: TDBPool;
const Plugin, Task: string): Integer;
var
P: TJSONObject;
begin
P := TJSONObject.Create;
try
P.Add('pn', Plugin);
P.Add('tn', Task);
Result := Pool.QueryInt(
'SELECT COUNT(*) FROM scheduler_log WHERE plugin_name = :pn ' +
'AND task_name = :tn', P);
finally
P.Free;
end;
end;
{ ---- Scenarios ---- }
procedure TestInit;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Tasks: TJSONArray;
begin
Writeln('-- init: Create declares tables');
DBFile := MakeTempDB('init');
Pool := MakePool(DBFile);
try
Sched := TCron.Create(Pool);
try
Check('system_scheduler table created',
Pool.TableExists('system_scheduler'));
Check('scheduler_log table created',
Pool.TableExists('scheduler_log'));
Tasks := Sched.GetTasksJSON;
try
Check('GetTasksJSON returns 0 tasks initially',
Tasks.Count = 0);
finally
Tasks.Free;
end;
finally
Sched.Free;
end;
finally
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestIntervalFires;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
begin
Writeln('-- interval task fires repeatedly');
DBFile := MakeTempDB('interval');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'plug1', 'task1', 1, 'plugin', True, True);
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, nil,
@Rec.HandleLog);
try
Sched.Start;
Sleep(3500);
Sched.Terminate;
finally
Sched.Free;
end;
Check(Format('plugin task fired at least 2 times (got %d)',
[Rec.PluginCalls.Count]),
Rec.PluginCalls.Count >= 2);
Check('scheduler_log has matching rows',
CountSchedulerLogRows(Pool, 'plug1', 'task1') >= 2);
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestSystemTaskFires;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
begin
Writeln('-- system task dispatch via RegisterSystemTask');
DBFile := MakeTempDB('system');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'system', 'cleanup', 1, 'general', True, True);
Sched := TCron.Create(Pool, nil, nil, @Rec.HandleLog);
try
Sched.RegisterSystemTask('cleanup', @Rec.HandleSystemTask);
Sched.Start;
Sleep(2500);
Sched.Terminate;
finally
Sched.Free;
end;
Check(Format('system task fired at least once (got %d)',
[Rec.SystemCalls.Count]),
Rec.SystemCalls.Count >= 1);
Check('scheduler_log records system success',
Pool.QueryInt(
'SELECT COUNT(*) FROM scheduler_log WHERE plugin_name = ''system''' +
' AND task_name = ''cleanup'' AND result = ''success''') >= 1);
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestSystemTaskFailureRecorded;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
begin
Writeln('-- system task that raises is recorded as error');
DBFile := MakeTempDB('failing');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'system', 'fails', 1, 'general', True, True);
Sched := TCron.Create(Pool, nil, nil, @Rec.HandleLog);
try
Sched.RegisterSystemTask('fails', @Rec.HandleSystemTaskFailing);
Sched.Start;
Sleep(2500);
Sched.Terminate;
finally
Sched.Free;
end;
Check('failing system task entered',
Rec.SystemCalls.Count >= 1);
Check('scheduler_log records error',
Pool.QueryInt(
'SELECT COUNT(*) FROM scheduler_log WHERE plugin_name = ''system''' +
' AND task_name = ''fails'' AND result = ''error''') >= 1);
Check('system_scheduler fail_count incremented',
Pool.QueryInt(
'SELECT fail_count FROM system_scheduler ' +
'WHERE plugin_name = ''system'' AND task_name = ''fails''') >= 1);
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestUpdateTaskDisables;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
TaskID: Int64;
Updates: TJSONObject;
CountAtDisable, CountAfterWait: Integer;
begin
Writeln('-- UpdateTask({enabled:false}) stops further firings');
DBFile := MakeTempDB('disable');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'plug', 'taskd', 1, 'plugin', True, True);
TaskID := Pool.QueryInt(
'SELECT id FROM system_scheduler WHERE task_name = ''taskd''');
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, nil,
@Rec.HandleLog);
try
Sched.Start;
Sleep(2500);
CountAtDisable := Rec.PluginCalls.Count;
Updates := TJSONObject.Create;
try
Updates.Add('enabled', False);
Check('UpdateTask returns True',
Sched.UpdateTask(TaskID, Updates));
finally
Updates.Free;
end;
Sleep(2500);
CountAfterWait := Rec.PluginCalls.Count;
Sched.Terminate;
finally
Sched.Free;
end;
Check(Format('saw firings before disable (got %d)',
[CountAtDisable]),
CountAtDisable >= 1);
Check(Format('no further firings after disable (was %d, now %d)',
[CountAtDisable, CountAfterWait]),
CountAfterWait = CountAtDisable);
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestRefreshTasks;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
begin
Writeln('-- RefreshTasks pulls a row added externally and runs it');
DBFile := MakeTempDB('refresh');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, nil,
@Rec.HandleLog);
try
Sched.Start;
Sleep(1200);
Check('no fires before task is added',
Rec.PluginCalls.Count = 0);
InsertTaskRow(Pool, 'late', 'incoming', 1, 'plugin', True, True);
Sched.RefreshTasks;
Sleep(2500);
Sched.Terminate;
finally
Sched.Free;
end;
Check(Format('post-refresh task fired (got %d)',
[Rec.PluginCalls.Count]),
Rec.PluginCalls.Count >= 1);
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestRunTaskNow;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
TaskID: Int64;
begin
Writeln('-- RunTaskNow fires synchronously without starting the thread');
DBFile := MakeTempDB('runnow');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'plug', 'now', 60, 'plugin', True, False);
TaskID := Pool.QueryInt(
'SELECT id FROM system_scheduler WHERE task_name = ''now''');
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, nil,
@Rec.HandleLog);
try
Check('task is registered',
TaskID > 0);
Sched.RunTaskNow(TaskID);
Check('RunTaskNow fired the callback',
Rec.PluginCalls.Count = 1);
Check('scheduler_log row written',
CountSchedulerLogRows(Pool, 'plug', 'now') = 1);
finally
Sched.Free;
end;
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
{ ---- SyncPluginTasks via callback ---- }
type
TPluginTaskSupplier = class
public
Tasks: TJSONArray;
constructor Create;
destructor Destroy; override;
procedure SetTasks(ATasks: TJSONArray);
function GetTasks: TJSONArray;
end;
constructor TPluginTaskSupplier.Create;
begin
inherited Create;
Tasks := nil;
end;
destructor TPluginTaskSupplier.Destroy;
begin
Tasks := nil;
inherited Destroy;
end;
procedure TPluginTaskSupplier.SetTasks(ATasks: TJSONArray);
begin
Tasks := ATasks;
end;
function TPluginTaskSupplier.GetTasks: TJSONArray;
var
Cloned: TJSONArray;
begin
{ The scheduler frees the returned array. Hand back a clone so
the caller can keep referring to its template. }
if Tasks = nil then
Cloned := TJSONArray.Create
else
Cloned := TJSONArray(Tasks.Clone);
Result := Cloned;
end;
procedure TestSyncPluginTasksRegisters;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
Sup: TPluginTaskSupplier;
Tasks: TJSONArray;
Obj: TJSONObject;
begin
Writeln('-- SyncPluginTasks inserts a row from the callback');
DBFile := MakeTempDB('syncadd');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
Sup := TPluginTaskSupplier.Create;
Tasks := TJSONArray.Create;
try
Obj := TJSONObject.Create;
Obj.Add('plugin_name', 'discovered');
Obj.Add('task_name', 'periodic');
Obj.Add('description', 'demo');
Obj.Add('interval_seconds', 60);
Tasks.Add(Obj);
Sup.SetTasks(Tasks);
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, @Sup.GetTasks,
@Rec.HandleLog);
try
Check('row inserted into system_scheduler',
Pool.QueryInt(
'SELECT COUNT(*) FROM system_scheduler ' +
'WHERE plugin_name = ''discovered'' AND task_name = ''periodic''') = 1);
Check('description carried through',
Pool.QueryStr(
'SELECT description FROM system_scheduler ' +
'WHERE plugin_name = ''discovered''') = 'demo');
finally
Sched.Free;
end;
finally
Tasks.Free;
Sup.Free;
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestSyncPluginTasksRemovesOrphans;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
Sup: TPluginTaskSupplier;
Tasks: TJSONArray;
Obj: TJSONObject;
begin
Writeln('-- SyncPluginTasks removes rows whose plugin no longer reports them');
DBFile := MakeTempDB('syncorph');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
Sup := TPluginTaskSupplier.Create;
{ Pre-populate two plugin rows. The supplier will only re-report
one of them, so the other should be deleted. }
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'kept', 'a', 60, 'plugin', True, False);
InsertTaskRow(Pool, 'removed', 'b', 60, 'plugin', True, False);
Tasks := TJSONArray.Create;
try
Obj := TJSONObject.Create;
Obj.Add('plugin_name', 'kept');
Obj.Add('task_name', 'a');
Obj.Add('description', 'still here');
Obj.Add('interval_seconds', 60);
Tasks.Add(Obj);
Sup.SetTasks(Tasks);
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, @Sup.GetTasks,
@Rec.HandleLog);
try
Check('kept row survived',
Pool.QueryInt(
'SELECT COUNT(*) FROM system_scheduler ' +
'WHERE plugin_name = ''kept''') = 1);
Check('removed row was deleted',
Pool.QueryInt(
'SELECT COUNT(*) FROM system_scheduler ' +
'WHERE plugin_name = ''removed''') = 0);
finally
Sched.Free;
end;
finally
Tasks.Free;
Sup.Free;
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestTypedCallbacksFire;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
I: Integer;
SawStart, SawComplete: Boolean;
begin
Writeln('-- typed observers OnTaskStart + OnTaskComplete fire');
DBFile := MakeTempDB('evbus');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'plug', 'event_check', 1, 'plugin', True, True);
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, nil,
@Rec.HandleLog);
try
Sched.OnTaskStart := @Rec.HandleTaskStart;
Sched.OnTaskComplete := @Rec.HandleTaskComplete;
Sched.Start;
Sleep(2200);
Sched.Terminate;
finally
Sched.Free;
end;
SawStart := False;
SawComplete := False;
for I := 0 to Rec.Events.Count - 1 do
begin
if Pos('start:plug/event_check', Rec.Events[I]) = 1 then SawStart := True;
if Pos('complete:plug/event_check=ok', Rec.Events[I]) = 1 then SawComplete := True;
end;
Check('OnTaskStart fired at least once', SawStart);
Check('OnTaskComplete fired at least once', SawComplete);
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestNextRunUTCRoundtrip;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
Tasks: TJSONArray;
Obj: TJSONObject;
NextRunStr: string;
begin
Writeln('-- NextRun is recalculated and persisted on Create');
DBFile := MakeTempDB('nextrun');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
InsertTaskRow(Pool, 'plug', 'r', 60, 'plugin', True, False);
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, nil,
@Rec.HandleLog);
try
Tasks := Sched.GetTasksJSON;
try
Check('exactly one task', Tasks.Count = 1);
Obj := TJSONObject(Tasks.Items[0]);
NextRunStr := Obj.Get('next_run', '');
Check('next_run is set after Create',
NextRunStr <> '');
finally
Tasks.Free;
end;
finally
Sched.Free;
end;
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestCronTaskExecutes;
var
DBFile: string;
Pool: TDBPool;
Sched: TCron;
Rec: TRecorder;
TaskID: Int64;
NextRunBefore, NextRunAfter: string;
begin
Writeln('-- cron task fires through ExecuteTask and NextRun moves forward');
DBFile := MakeTempDB('cron');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
EnsureSchedulerSchema(Pool);
Pool.ExecSQL(
'INSERT INTO system_scheduler ' +
'(task_name, plugin_name, schedule_type, cron_expr, enabled) VALUES ' +
'(''nightly'', ''demo'', ''cron'', ''* * * * *'', 1)');
TaskID := Pool.QueryInt(
'SELECT id FROM system_scheduler WHERE task_name = ''nightly''');
Sched := TCron.Create(Pool, @Rec.HandlePluginTask, nil,
@Rec.HandleLog);
try
NextRunBefore := Pool.QueryStr(
'SELECT next_run FROM system_scheduler WHERE id = ' +
IntToStr(TaskID));
Check('NextRun seeded after Create',
NextRunBefore <> '');
Sched.RunTaskNow(TaskID);
Check('cron task fired exactly once',
Rec.PluginCalls.Count = 1);
Check('scheduler_log records the cron fire',
CountSchedulerLogRows(Pool, 'demo', 'nightly') = 1);
NextRunAfter := Pool.QueryStr(
'SELECT next_run FROM system_scheduler WHERE id = ' +
IntToStr(TaskID));
{ CalcNextRunCron is deterministic-by-minute, so the post-fire
NextRun may equal the pre-fire one if both evaluations land
in the same minute. Verify the cron path ran by checking
run_count + last_run instead. }
Check('NextRun is still set after fire',
NextRunAfter <> '');
Check('run_count incremented to 1',
Pool.QueryInt(
'SELECT run_count FROM system_scheduler WHERE id = ' +
IntToStr(TaskID)) = 1);
Check('last_run populated after fire',
Pool.QueryStr(
'SELECT last_run FROM system_scheduler WHERE id = ' +
IntToStr(TaskID)) <> '');
finally
Sched.Free;
end;
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
procedure TestTwoSchedulersOnePool;
var
DBFile: string;
Pool: TDBPool;
A, B: TCron;
Rec: TRecorder;
begin
Writeln('-- two TCron instances on one pool: second DeclareTable is a no-op');
DBFile := MakeTempDB('twosched');
Pool := MakePool(DBFile);
Rec := TRecorder.Create;
try
A := TCron.Create(Pool, nil, nil, @Rec.HandleLog);
Check('first scheduler created without error',
Assigned(A));
Check('first Create declared system_scheduler',
Pool.TableExists('system_scheduler'));
try
B := TCron.Create(Pool, nil, nil, @Rec.HandleLog);
try
Check('second scheduler created without error (idempotent DeclareTable)',
Assigned(B));
Check('table still present after second Create',
Pool.TableExists('system_scheduler'));
finally
B.Free;
end;
finally
A.Free;
end;
finally
Rec.Free;
Pool.Free;
DeleteFile(DBFile);
end;
end;
begin
Total := 0; Passed := 0; Failed := 0;
Randomize;
TestInit;
TestNextRunUTCRoundtrip;
TestRunTaskNow;
TestSyncPluginTasksRegisters;
TestSyncPluginTasksRemovesOrphans;
TestIntervalFires;
TestSystemTaskFires;
TestSystemTaskFailureRecorded;
TestUpdateTaskDisables;
TestRefreshTasks;
TestTypedCallbacksFire;
TestCronTaskExecutes;
TestTwoSchedulersOnePool;
Writeln;
Writeln(Format('Total: %d Passed: %d Failed: %d',
[Total, Passed, Failed]));
if Failed > 0 then Halt(1);
end.