@@ -1,0 +1,268 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Test.StorageConformance do
@moduledoc """
Shared contract suite for `ExGitObjectstore.Storage` backends.
Every conforming implementation (Filesystem, S3, Memory, …) must
pass these tests. Backend-specific scenarios (FS lock files, S3
pagination, etc.) stay in each backend's own test module *outside*
the `use` block; this module covers only the common contract.
## Usage
defmodule MyBackendTest do
use ExUnit.Case, async: true
use ExGitObjectstore.Test.StorageConformance
setup do
# Construct a fresh repo and return %{repo: repo}
%{repo: build_repo()}
end
# Backend-specific tests below — these run alongside the
# contract scenarios and have full access to the same setup.
describe "MyBackend-specific quirks" do
...
end
end
The `setup` must produce at least `%{repo: repo}`. Anything else
the backend tests want (e.g. `:root` for Filesystem) can be added
alongside.
## Why this exists
Before this module, each backend re-implemented the same ~20
scenarios in its own file. The duplication had two failure modes:
1. Backends drifted — Filesystem tested ref CAS one way,
S3 tested it another, neither caught the same edge cases.
2. The `:s3` exclusion in `test_helper.exs` hid the fact that
the S3 backend wasn't running. With a shared contract module
used by every backend, "S3 isn't tested" surfaces immediately
as missing coverage rather than as a green-but-empty matrix.
Backend-specific tests still belong in each backend's file because
they exercise behavior unique to that backend (FS file locks, S3
list-pagination continuation tokens, etc.).
"""
defmacro __using__(_opts) do
quote do
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
describe "object storage (contract)" do
test "write and read a blob", %{repo: repo} do
blob = Blob.from_content("hello world")
assert {:ok, sha} = Object.write(repo, blob)
assert String.length(sha) == 40
assert {:ok, %Blob{content: "hello world"}} = Object.read(repo, sha)
end
test "write and read a tree", %{repo: repo} do
blob = Blob.from_content("file content")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
{:ok, %Tree{entries: [entry]}} = Object.read(repo, tree_sha)
assert entry.name == "file.txt"
assert entry.sha == blob_sha
end
test "write and read a commit", %{repo: repo} do
tree_sha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1234567890 +0000",
committer: "Test <test@test.com> 1234567890 +0000",
message: "init\n"
}
{:ok, sha} = Object.write(repo, commit)
{:ok, %Commit{} = decoded} = Object.read(repo, sha)
assert decoded.tree == tree_sha
assert decoded.message == "init\n"
end
test "read non-existent object returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Object.read(repo, String.duplicate("0", 40))
end
test "object_exists? reflects presence", %{repo: repo} do
blob = Blob.from_content("exists check")
sha = Object.hash(blob)
refute Repo.storage_call(repo, :object_exists?, [sha])
{:ok, _} = Object.write(repo, blob)
assert Repo.storage_call(repo, :object_exists?, [sha])
end
end
describe "ref storage (contract)" do
test "put and get ref", %{repo: repo} do
sha = String.duplicate("a", 40)
assert :ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha, nil])
assert {:ok, ^sha} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "get non-existent ref returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/nope"])
end
test "CAS put_ref succeeds with matching old_sha", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
assert :ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, sha1])
assert {:ok, ^sha2} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "CAS put_ref fails with mismatched old_sha", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
wrong = String.duplicate("c", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
assert {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, wrong])
end
test "CAS put_ref fails when ref does not exist", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
assert {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, sha2])
end
test "delete ref", %{repo: repo} do
sha = String.duplicate("a", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha, nil])
:ok = Repo.storage_call(repo, :delete_ref, ["refs/heads/main"])
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "list refs under prefix", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/dev", sha2, nil])
:ok = Repo.storage_call(repo, :put_ref, ["refs/tags/v1", sha1, nil])
{:ok, heads} = Repo.storage_call(repo, :list_refs, ["refs/heads/"])
assert length(heads) == 2
ref_names = Enum.map(heads, &elem(&1, 0))
assert "refs/heads/main" in ref_names
assert "refs/heads/dev" in ref_names
refute "refs/tags/v1" in ref_names
end
end
describe "HEAD storage (contract)" do
test "put and get HEAD", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_head, ["ref: refs/heads/main"])
assert {:ok, "ref: refs/heads/main"} = Repo.storage_call(repo, :get_head, [])
end
test "get HEAD when not set returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_head, [])
end
end
describe "pack storage (contract)" do
test "put and get pack", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["abc123", "pack-data", "idx-data"])
assert {:ok, "pack-data"} = Repo.storage_call(repo, :get_pack, ["abc123"])
assert {:ok, "idx-data"} = Repo.storage_call(repo, :get_pack_index, ["abc123"])
end
test "get non-existent pack returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack, ["nonexistent"])
end
test "get non-existent pack index returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack_index, ["nonexistent"])
end
test "list packs", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["pack1", "data1", "idx1"])
:ok = Repo.storage_call(repo, :put_pack, ["pack2", "data2", "idx2"])
{:ok, packs} = Repo.storage_call(repo, :list_packs, [])
assert "pack1" in packs
assert "pack2" in packs
end
test "list packs returns empty when none exist", %{repo: repo} do
assert {:ok, []} = Repo.storage_call(repo, :list_packs, [])
end
test "stream_pack returns enumerable", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["streamtest", "stream-data", "idx"])
{:ok, stream} = Repo.storage_call(repo, :stream_pack, ["streamtest"])
result = stream |> Enum.to_list() |> IO.iodata_to_binary()
assert result == "stream-data"
end
test "stream_pack returns :not_found for non-existent pack", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :stream_pack, ["nope"])
end
end
describe "blob storage (contract)" do
test "put and get blob round-trips", %{repo: repo} do
assert :ok = Repo.storage_call(repo, :put_blob, ["k/v", "blob-data"])
assert {:ok, "blob-data"} = Repo.storage_call(repo, :get_blob, ["k/v"])
end
test "get on missing blob returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_blob, ["missing"])
end
test "put_blob overwrites existing blob", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_blob, ["k", "v1"])
:ok = Repo.storage_call(repo, :put_blob, ["k", "v2"])
assert {:ok, "v2"} = Repo.storage_call(repo, :get_blob, ["k"])
end
test "blob_exists? reflects presence", %{repo: repo} do
refute Repo.storage_call(repo, :blob_exists?, ["maybe"])
:ok = Repo.storage_call(repo, :put_blob, ["maybe", "yes"])
assert Repo.storage_call(repo, :blob_exists?, ["maybe"])
end
test "delete_blob removes the blob", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_blob, ["bye", "data"])
:ok = Repo.storage_call(repo, :delete_blob, ["bye"])
assert {:error, :not_found} = Repo.storage_call(repo, :get_blob, ["bye"])
end
test "delete_blob on missing key is :ok", %{repo: repo} do
assert :ok = Repo.storage_call(repo, :delete_blob, ["never"])
end
end
end
end
end