diff --git a/lib/towerops/preseem/sync_log.ex b/lib/towerops/preseem/sync_log.ex new file mode 100644 index 00000000..ca2dee7b --- /dev/null +++ b/lib/towerops/preseem/sync_log.ex @@ -0,0 +1,41 @@ +defmodule Towerops.Preseem.SyncLog do + @moduledoc """ + Audit log for Preseem sync operations. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @valid_statuses ~w(success partial failed) + + schema "preseem_sync_logs" do + field :status, :string + field :records_synced, :integer, default: 0 + field :errors, :map + field :duration_ms, :integer + field :inserted_at, :utc_datetime + + belongs_to :organization, Towerops.Organizations.Organization + belongs_to :integration, Towerops.Integrations.Integration + end + + def changeset(sync_log, attrs) do + sync_log + |> cast(attrs, [ + :organization_id, + :integration_id, + :status, + :records_synced, + :errors, + :duration_ms, + :inserted_at + ]) + |> validate_required([:organization_id, :integration_id, :status, :inserted_at]) + |> validate_inclusion(:status, @valid_statuses) + |> foreign_key_constraint(:organization_id) + |> foreign_key_constraint(:integration_id) + end +end diff --git a/priv/repo/migrations/20260212231109_create_preseem_sync_logs.exs b/priv/repo/migrations/20260212231109_create_preseem_sync_logs.exs new file mode 100644 index 00000000..c2598a62 --- /dev/null +++ b/priv/repo/migrations/20260212231109_create_preseem_sync_logs.exs @@ -0,0 +1,26 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemSyncLogs do + use Ecto.Migration + + def change do + create table(:preseem_sync_logs, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :integration_id, references(:integrations, type: :binary_id, on_delete: :delete_all), + null: false + + add :status, :string, null: false + add :records_synced, :integer, default: 0 + add :errors, :map + add :duration_ms, :integer + + add :inserted_at, :utc_datetime, null: false + end + + create index(:preseem_sync_logs, [:organization_id]) + create index(:preseem_sync_logs, [:integration_id]) + create index(:preseem_sync_logs, [:inserted_at]) + end +end diff --git a/test/towerops/preseem/sync_log_test.exs b/test/towerops/preseem/sync_log_test.exs new file mode 100644 index 00000000..31692682 --- /dev/null +++ b/test/towerops/preseem/sync_log_test.exs @@ -0,0 +1,84 @@ +defmodule Towerops.Preseem.SyncLogTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.IntegrationsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.SyncLog + + setup do + user = user_fixture() + org = organization_fixture(user.id) + integration = integration_fixture(org.id) + %{organization: org, integration: integration} + end + + describe "changeset/2" do + test "valid changeset", %{organization: org, integration: integration} do + attrs = %{ + organization_id: org.id, + integration_id: integration.id, + status: "success", + records_synced: 42, + duration_ms: 1500, + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = SyncLog.changeset(%SyncLog{}, attrs) + assert changeset.valid? + end + + test "requires organization_id and integration_id" do + attrs = %{status: "success", inserted_at: DateTime.truncate(DateTime.utc_now(), :second)} + changeset = SyncLog.changeset(%SyncLog{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + assert "can't be blank" in errors_on(changeset).integration_id + end + + test "requires status" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + integration_id: Ecto.UUID.generate(), + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = SyncLog.changeset(%SyncLog{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).status + end + + test "validates status values" do + base = %{ + organization_id: Ecto.UUID.generate(), + integration_id: Ecto.UUID.generate(), + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + for status <- ~w(success partial failed) do + changeset = SyncLog.changeset(%SyncLog{}, Map.put(base, :status, status)) + assert changeset.valid?, "Expected #{status} to be valid" + end + + changeset = SyncLog.changeset(%SyncLog{}, Map.put(base, :status, "bogus")) + refute changeset.valid? + end + + test "stores error details", %{organization: org, integration: integration} do + errors = %{"message" => "API timeout", "code" => 504} + + attrs = %{ + organization_id: org.id, + integration_id: integration.id, + status: "failed", + errors: errors, + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + {:ok, log} = %SyncLog{} |> SyncLog.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(SyncLog, log.id) + assert reloaded.errors == errors + end + end +end