diff --git a/pygitweb/projects_test.py b/pygitweb/projects_test.py
new file mode 100644
index 0000000..3a4acb1
--- /dev/null
+++ b/pygitweb/projects_test.py
@@ -0,0 +1,186 @@
+from __future__ import annotations
+
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+
+import pygitweb.projects as projects
+from pygitweb.config import settings
+
+
+def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path, message: str) -> None:
+	(repo_dir / "README.md").write_text(f"{message}\n", encoding="utf-8")
+	index = repo.index
+	index.add("README.md")
+	index.write()
+	tree = index.write_tree()
+	sig = pygit2.Signature("tester", "tester@example.com")
+	repo.create_commit("HEAD", sig, sig, message, tree, [])
+
+
+class TestProjects:
+	@pytest.fixture(scope="class")
+	def project_env(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
+		root: Path = tmp_path_factory.mktemp("projects")
+
+		alpha_dir: Path = root / "alpha"
+		alpha_dir.mkdir()
+		alpha_repo = pygit2.init_repository(str(alpha_dir), bare=False)
+		_create_initial_commit(alpha_repo, alpha_dir, "alpha init")
+		(alpha_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+
+		nested_dir: Path = root / "group"
+		nested_dir.mkdir()
+		beta_dir: Path = nested_dir / "beta"
+		beta_dir.mkdir()
+		beta_repo = pygit2.init_repository(str(beta_dir), bare=False)
+		_create_initial_commit(beta_repo, beta_dir, "beta init")
+		(beta_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+
+		private_dir: Path = root / "private"
+		private_dir.mkdir()
+		private_repo = pygit2.init_repository(str(private_dir), bare=False)
+		_create_initial_commit(private_repo, private_dir, "private init")
+
+		(root / "not-a-repo").mkdir()
+
+		projects_list_file: Path = root / "projects.list"
+		projects_list_file.write_text(
+			"\n".join([
+				"alpha Alice",
+				"group/beta Bob%20Owner",
+				"private",
+				"name%20with%20space Owner%20Space",
+			])
+			+ "\n",
+			encoding="utf-8",
+		)
+
+		try:
+			yield {
+				"root": str(root),
+				"alpha": "alpha",
+				"beta": "group/beta",
+				"private": "private",
+				"projects_list_file": str(projects_list_file),
+			}
+		finally:
+			projects._gitweb_project_owner = None
+
+	class TestProjectInList:
+		def test_project_in_list_true(self) -> None:
+			def getter() -> list[dict[str, str]]:
+				return [{"path": "alpha"}, {"path": "beta"}]
+
+			assert projects.project_in_list("alpha", getter) is True
+
+		def test_project_in_list_false(self) -> None:
+			def getter() -> list[dict[str, str]]:
+				return [{"path": "alpha"}]
+
+			assert projects.project_in_list("missing", getter) is False
+
+	class TestProjectDiscovery:
+		def test_git_get_projects_list_directory_mode(self, project_env: dict[str, str]) -> None:
+			with (
+				patch.object(settings, "PROJECTROOT", project_env["root"]),
+				patch.object(settings, "PROJECTS_LIST", project_env["root"]),
+				patch.object(settings, "PROJECT_MAXDEPTH", 3),
+				patch.object(settings, "LIST_ALL", True),
+			):
+				result = projects.git_get_projects_list(export_ok="git-daemon-export-ok")
+			paths = {entry["path"] for entry in result}
+			assert {"alpha", "group/beta", "private"} <= paths
+
+		def test_git_get_projects_list_respects_export_ok_when_list_all_false(
+			self,
+			project_env: dict[str, str],
+		) -> None:
+			with (
+				patch.object(settings, "PROJECTROOT", project_env["root"]),
+				patch.object(settings, "PROJECTS_LIST", project_env["root"]),
+				patch.object(settings, "PROJECT_MAXDEPTH", 3),
+				patch.object(settings, "LIST_ALL", False),
+			):
+				result = projects.git_get_projects_list(export_ok="git-daemon-export-ok")
+			paths = {entry["path"] for entry in result}
+			assert "alpha" in paths
+			assert "group/beta" in paths
+			assert "private" not in paths
+
+		def test_git_get_projects_list_filter_path(self, project_env: dict[str, str]) -> None:
+			with (
+				patch.object(settings, "PROJECTROOT", project_env["root"]),
+				patch.object(settings, "PROJECTS_LIST", project_env["root"]),
+				patch.object(settings, "PROJECT_MAXDEPTH", 3),
+				patch.object(settings, "LIST_ALL", True),
+			):
+				result = projects.git_get_projects_list(filter_path="group")
+			assert result == [{"path": "group/beta"}]
+
+		def test_git_get_projects_list_file_mode(self, project_env: dict[str, str]) -> None:
+			with (
+				patch.object(settings, "PROJECTROOT", project_env["root"]),
+				patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
+				patch.object(settings, "LIST_ALL", True),
+			):
+				result = projects.git_get_projects_list()
+			assert result == [
+				{"path": "alpha", "owner": "Alice"},
+				{"path": "group/beta", "owner": "Bob Owner"},
+				{"path": "private"},
+				{"path": "name with space", "owner": "Owner Space"},
+			]
+
+	class TestProjectOwnerAndActivity:
+		def test_git_get_project_list_from_file_and_cache(self, project_env: dict[str, str]) -> None:
+			projects._gitweb_project_owner = None
+			with patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]):
+				owners_first = projects.git_get_project_list_from_file()
+				owners_second = projects.git_get_project_list_from_file()
+			assert owners_first is owners_second
+			assert owners_first["alpha"] == "Alice"
+			assert owners_first["group/beta"] == "Bob Owner"
+
+		def test_git_get_project_owner_from_file(self, project_env: dict[str, str]) -> None:
+			projects._gitweb_project_owner = None
+			with (
+				patch.object(settings, "PROJECTROOT", project_env["root"]),
+				patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
+			):
+				owner = projects.git_get_project_owner("alpha")
+			assert owner == "Alice"
+
+		def test_git_get_project_owner_from_config_fallback(self, project_env: dict[str, str]) -> None:
+			projects._gitweb_project_owner = None
+			with (
+				patch.object(settings, "PROJECTROOT", project_env["root"]),
+				patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
+			):
+				owner = projects.git_get_project_owner("private", get_project_config=lambda _p, _k: "Config Owner")
+			assert owner == "Config Owner"
+
+		def test_git_get_last_activity_for_repo_and_missing_project(self, project_env: dict[str, str]) -> None:
+			with patch.object(settings, "PROJECTROOT", project_env["root"]):
+				last_activity = projects.git_get_last_activity(project_env["alpha"])
+				missing_activity = projects.git_get_last_activity("does-not-exist")
+			assert isinstance(last_activity, int)
+			assert last_activity > 0
+			assert missing_activity is None
+
+	class TestSearchProjects:
+		def test_search_projects_list_by_tag_and_regex(self) -> None:
+			projlist: list[dict[str, object]] = [
+				{"path": "alpha", "descr_long": "backend api", "ctags": {"infra": 1}},
+				{"path": "beta", "descr_long": "frontend web", "ctags": {"ui": 1}},
+			]
+			result = projects.search_projects_list(projlist, tagfilter="infra", search_regexp="backend")
+			assert result == [projlist[0]]
+
+		def test_search_projects_list_invalid_regex_returns_empty(self) -> None:
+			projlist: list[dict[str, object]] = [{"path": "alpha", "descr_long": "desc"}]
+			result = projects.search_projects_list(projlist, search_regexp="(")
+			assert result == []
