Merge "Add support for custom quota-exceeded messages"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java
index 304b092..2450760 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java
@@ -24,6 +24,7 @@
import com.google.common.cache.LoadingCache;
import com.google.common.collect.Ordering;
import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.server.cache.CacheModule;
@@ -47,6 +48,7 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
@@ -92,13 +94,13 @@
this.projectCache = projectCache;
}
- protected Optional<Long> getMaxPackSize(Project.NameKey project) {
+ protected Optional<AvailableSizeResponse> getMaxPackSize(Project.NameKey project) {
return getMaxPackSize(project, true);
}
- protected Optional<Long> getMaxPackSize(
+ protected Optional<AvailableSizeResponse> getMaxPackSize(
Project.NameKey project, boolean requireProjectExistence) {
- List<Long> maxPackCandidates = new ArrayList<>();
+ List<AvailableSizeResponse> maxPackCandidates = new ArrayList<>();
getMaxPackSize(quotaFinder.firstMatching(project), project, requireProjectExistence)
.ifPresent(maxPackCandidates::add);
getMaxPackSize(quotaFinder.getGlobalNamespacedQuota(), project, requireProjectExistence)
@@ -106,10 +108,12 @@
return maxPackCandidates.isEmpty()
? Optional.empty()
- : Optional.of(Collections.min(maxPackCandidates));
+ : Optional.of(
+ Collections.min(
+ maxPackCandidates, Comparator.comparingLong(AvailableSizeResponse::availableSize)));
}
- protected Optional<Long> getMaxPackSize(
+ protected Optional<AvailableSizeResponse> getMaxPackSize(
QuotaSection quotaSection, Project.NameKey project, boolean requireProjectExistence) {
if (quotaSection == null) {
return Optional.empty();
@@ -142,8 +146,22 @@
maxPackSize2 = Math.max(0, maxTotalSize - totalSize);
}
- return Optional.ofNullable(
- Ordering.<Long>natural().nullsLast().min(maxPackSize1, maxPackSize2));
+ Long chosenAvailable = Ordering.<Long>natural().nullsLast().min(maxPackSize1, maxPackSize2);
+
+ if (chosenAvailable == null) {
+ return Optional.empty();
+ }
+
+ Long maximumSize;
+ if (chosenAvailable.equals(maxPackSize1)) {
+ maximumSize = maxRepoSize;
+ } else {
+ maximumSize = maxTotalSize;
+ }
+
+ return Optional.of(
+ new AvailableSizeResponse(quotaSection, project, chosenAvailable, maximumSize));
+
} catch (ExecutionException e) {
log.warn("Couldn't calculate maxPackSize for {}", project, e);
return Optional.empty();
@@ -261,7 +279,7 @@
return ctx.project()
.flatMap(p -> getMaxPackSize(p, false))
- .map(v -> requestQuota(ctx, numTokens, v, false))
+ .map(v -> requestQuota(numTokens, v, false))
.orElse(noOp());
}
@@ -289,8 +307,8 @@
}
return ctx.project()
- .flatMap(p -> getMaxPackSize(p))
- .map(v -> requestQuota(ctx, numTokens, v, true))
+ .flatMap(this::getMaxPackSize)
+ .map(v -> requestQuota(numTokens, v, true))
.orElse(noOp());
}
@@ -299,13 +317,16 @@
if (!REPOSITORY_SIZE_GROUP.equals(quotaGroup)) {
return noOp();
}
- return ctx.project().flatMap(p -> getMaxPackSize(p)).map(v -> ok(v)).orElse(noOp());
+ return ctx.project()
+ .flatMap(this::getMaxPackSize)
+ .map(v -> ok(v.availableSize(), v.exceededSizeMessage()))
+ .orElse(noOp());
}
private QuotaResponse requestQuota(
- QuotaRequestContext ctx, long requested, Long availableSpace, boolean deduct) {
- Project.NameKey r = ctx.project().get();
- if (availableSpace >= requested) {
+ long requested, AvailableSizeResponse availableSizeResponse, boolean deduct) {
+ Project.NameKey r = availableSizeResponse.project();
+ if (availableSizeResponse.availableSize() >= requested) {
if (deduct) {
try {
cache.get(r).getAndAdd(requested);
@@ -318,9 +339,29 @@
return ok();
}
- return error(
- String.format(
- "Requested space [%d] is bigger then available [%d] for repository %s",
- requested, availableSpace, r));
+ return error(availableSizeResponse.withRequested(requested).exceededSizeMessage());
+ }
+
+ protected record AvailableSizeResponse(
+ QuotaSection quotaSection,
+ Project.NameKey project,
+ long availableSize,
+ long maximumSize,
+ @Nullable Long requested) {
+
+ public AvailableSizeResponse(
+ QuotaSection quotaSection, Project.NameKey project, long availableSize, long maximumSize) {
+ this(quotaSection, project, availableSize, maximumSize, null);
+ }
+
+ public AvailableSizeResponse withRequested(long newRequested) {
+ return new AvailableSizeResponse(
+ quotaSection, project, availableSize, maximumSize, newRequested);
+ }
+
+ public String exceededSizeMessage() {
+ return quotaSection()
+ .quotaSizeExceededMessage(project, availableSize, maximumSize, requested);
+ }
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
index 8de2ff8..8df6099 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
@@ -14,6 +14,7 @@
package com.googlesource.gerrit.plugins.quota;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import java.util.Arrays;
import java.util.List;
@@ -24,6 +25,13 @@
String KEY_MAX_PROJECTS = "maxProjects";
String KEY_MAX_REPO_SIZE = "maxRepoSize";
String KEY_MAX_TOTAL_SIZE = "maxTotalSize";
+ String KEY_SIZE_EXCEEDED_KNOWN_REQUEST_SIZE_MSG = "sizeLimitExceededKnownRequestSizeMsg";
+ String DEFAULT_SIZE_MSG_KNOWN_REQUEST_SIZE_TEMPLATE =
+ "Requested space [${requested}] is bigger then available [${available}] for repository"
+ + " ${project}";
+ String KEY_SIZE_EXCEEDED_UNKNOWN_REQUEST_SIZE_MSG = "sizeLimitExceededUnknownRequestSizeMsg";
+ String DEFAULT_SIZE_MSG_UNKNOWN_REQUEST_SIZE_TEMPLATE =
+ "Project ${project} exceeds quota: max=${maximum} bytes, available=${available} bytes.";
String getNamespace();
@@ -60,6 +68,34 @@
.toList();
}
+ default String quotaSizeExceededMessage(
+ Project.NameKey project, long availableSize, long maximumSize, @Nullable Long requested) {
+
+ return QuotaSizeMessageInterpolator.interpolate(
+ getQuotaSizeExceededMessageTemplate(requested),
+ project,
+ availableSize,
+ maximumSize,
+ requested);
+ }
+
+ private String getQuotaSizeExceededMessageTemplate(@Nullable Long requested) {
+ boolean unknown = (requested == null);
+ String msgTemplate =
+ unknown
+ ? KEY_SIZE_EXCEEDED_UNKNOWN_REQUEST_SIZE_MSG
+ : KEY_SIZE_EXCEEDED_KNOWN_REQUEST_SIZE_MSG;
+ String defaultMsgTemplate =
+ unknown
+ ? DEFAULT_SIZE_MSG_UNKNOWN_REQUEST_SIZE_TEMPLATE
+ : DEFAULT_SIZE_MSG_KNOWN_REQUEST_SIZE_TEMPLATE;
+ String tpl = cfg().getString(section(), subSection(), msgTemplate);
+ if (tpl == null || tpl.trim().isEmpty()) {
+ return defaultMsgTemplate;
+ }
+ return tpl;
+ }
+
default boolean isFallbackQuota() {
return false;
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolator.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolator.java
new file mode 100644
index 0000000..31095ab
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolator.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility class responsible for expanding variables in size-quota-exceeded message templates.
+ *
+ * <p>This interpolator replaces simple {@code ${var}} placeholders inside a message template with
+ * runtime values derived from size quota evaluation. It currently supports exactly three variables:
+ *
+ * <ul>
+ * <li>{@code ${project}} — expanded to the project name (e.g. {@code "foo/bar"}).
+ * <li>{@code ${available}} — the number of remaining bytes before quota is exceeded.
+ * <li>{@code ${maximum}} — the configured maximum allowed value for the applicable quota.
+ * <li>{@code ${requestedSize}} — requested size
+ * </ul>
+ *
+ * <p>Unknown variables are left untouched, allowing safe forward-compatibility and making it easier
+ * to spot configuration mistakes.
+ */
+class QuotaSizeMessageInterpolator {
+ private static final Pattern VAR_PATTERN = Pattern.compile("\\$\\{([^}]+)}");
+
+ /**
+ * Expands variables inside the quota-exceeded message template.
+ *
+ * <p>Supported variables:
+ *
+ * <ul>
+ * <li>{@code ${project}} — project name
+ * <li>{@code ${available}} — remaining quota in bytes/tokens
+ * <li>{@code ${maximum}} — configured upper limit for the quota
+ * <li>{@code ${requestedSize}} — requested size
+ * </ul>
+ *
+ * <p>Unknown placeholders are preserved unchanged, e.g. {@code ${foo}} remains {@code ${foo}}.
+ *
+ * @param template raw template read from configuration
+ * @param project the affected project
+ * @param availableSize remaining size/tokens before exceeding quota
+ * @param maximumSize configured maximum for the quota
+ * @param requestedSize requested size
+ * @return the template with variables replaced
+ */
+ static String interpolate(
+ String template,
+ Project.NameKey project,
+ long availableSize,
+ long maximumSize,
+ @Nullable Long requestedSize) {
+
+ Map<String, String> vars =
+ Map.of(
+ "project", project.get(),
+ "available", String.valueOf(availableSize),
+ "requested", Objects.toString(requestedSize, ""),
+ "maximum", String.valueOf(maximumSize));
+
+ Matcher m = VAR_PATTERN.matcher(template);
+ StringBuilder sb = new StringBuilder();
+
+ while (m.find()) {
+ String var = m.group(1);
+ String replacement = vars.getOrDefault(var, m.group(0));
+ m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
+ }
+
+ m.appendTail(sb);
+ return sb.toString();
+ }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index f2ceb15..2d3a925 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -22,6 +22,35 @@
maxTotalSize = 200 m
```
+<a id="sizeLimitExceededUnknownRequestSizeMsg" />
+`quota.<namespace>.sizeLimitExceededUnknownRequestSizeMsg`
+: Optional custom message returned when a size quota in this namespace is exceeded **and the
+requested size is not known in advance**.
+This situation occurs when the quota is used as a *pre-flight* check, but the exact number
+of bytes to be written is not yet available.
+For example, when Gerrit receives a commit, its size cannot be determined until the transfer
+completes.
+
+<a id="sizeLimitExceededKnownRequestSizeMsg" />
+`quota.<namespace>.sizeLimitExceededKnownRequestSizeMsg`
+: Optional custom message returned when a size quota in this namespace is exceeded **and the
+requested size is known upfront**.
+This applies when the quota is evaluated after the size is already determined, such as during
+a post-receive hook or when creating a repository.
+
+The message may contain the following placeholders, which will be replaced at
+runtime when constructing the client-facing error message:
+
+* `${project}` — the name of the affected project
+* `${available}` — remaining quota (in bytes) available before exceeding the limit
+* `${maximum}` — the configured maximum size (in bytes) enforced by the quota
+* `${requested}` — The requested size (in bytes), if known.
+
+Unknown placeholders are left unchanged. If no message is configured, a
+default message is used. When multiple quota enforcers contribute to a quota
+calculation, the message from the most restrictive enforcer (the one with the
+lowest remaining quota) is selected.
+
<a id="maxProjects" />
`quota.<namespace>.maxProjects`
: The maximum number of projects that can be created in this namespace.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/GlobalQuotaSectionTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/GlobalQuotaSectionTest.java
new file mode 100644
index 0000000..4740fa3
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/GlobalQuotaSectionTest.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import org.eclipse.jgit.lib.Config;
+
+public class GlobalQuotaSectionTest extends QuotaSectionBase {
+
+ @Override
+ protected QuotaSection createQuotaSection(Config cfg) {
+ return new GlobalQuotaSection(cfg);
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSectionTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSectionTest.java
new file mode 100644
index 0000000..7623b12
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSectionTest.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import org.eclipse.jgit.lib.Config;
+
+public class NamespacedQuotaSectionTest extends QuotaSectionBase {
+
+ @Override
+ protected QuotaSection createQuotaSection(Config cfg) {
+ return new NamespacedQuotaSection(cfg, "test/*");
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSectionBase.java b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSectionBase.java
new file mode 100644
index 0000000..c1b7204
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSectionBase.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class QuotaSectionBase {
+
+ protected static final Project.NameKey PROJECT = Project.nameKey("foo/bar");
+
+ protected abstract QuotaSection createQuotaSection(Config cfg);
+
+ @Test
+ public void fallsBackToDefaultExceededMessageUnknownRequestSizeWhenNotConfigured() {
+ Config cfg = new Config();
+ QuotaSection section = createQuotaSection(cfg);
+
+ String message = section.quotaSizeExceededMessage(PROJECT, 100L, 200L, null);
+
+ assertThat(message)
+ .isEqualTo("Project foo/bar exceeds quota: max=200 bytes, available=100 bytes.");
+ }
+
+ @Test
+ public void usesSizeExceededMessageUnknownRequestSizeWhenConfigured() {
+ Config cfg = new Config();
+ cfg.setString(
+ createQuotaSection(cfg).section(),
+ createQuotaSection(cfg).subSection(),
+ QuotaSection.KEY_SIZE_EXCEEDED_UNKNOWN_REQUEST_SIZE_MSG,
+ "Custom for ${project}: max=${maximum}, avail=${available}");
+ QuotaSection section = createQuotaSection(cfg);
+
+ String message = section.quotaSizeExceededMessage(PROJECT, 10L, 20L, null);
+
+ assertThat(message).isEqualTo("Custom for foo/bar: max=20, avail=10");
+ }
+
+ @Test
+ public void fallsBackToDefaultExceededMessageKnownRequestSizeWhenNotConfigured() {
+ Config cfg = new Config();
+ QuotaSection section = createQuotaSection(cfg);
+
+ String message = section.quotaSizeExceededMessage(PROJECT, 100L, 200L, 300L);
+
+ assertThat(message)
+ .isEqualTo("Requested space [300] is bigger then available [100] for repository foo/bar");
+ }
+
+ @Test
+ public void usesSizeExceededMessageKnownRequestSizeWhenConfigured() {
+ Config cfg = new Config();
+ cfg.setString(
+ createQuotaSection(cfg).section(),
+ createQuotaSection(cfg).subSection(),
+ QuotaSection.KEY_SIZE_EXCEEDED_KNOWN_REQUEST_SIZE_MSG,
+ "Custom for ${project}: max=${maximum}, avail=${available}, requested=${requested}");
+ QuotaSection section = createQuotaSection(cfg);
+
+ String message = section.quotaSizeExceededMessage(PROJECT, 10L, 20L, 30L);
+
+ assertThat(message).isEqualTo("Custom for foo/bar: max=20, avail=10, requested=30");
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolatorTest.java
new file mode 100644
index 0000000..d1ea706
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolatorTest.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import org.junit.Test;
+
+public class QuotaSizeMessageInterpolatorTest {
+
+ private static final Project.NameKey PROJECT = Project.nameKey("foo/bar");
+
+ @Test
+ public void interpolateReplacesAllVariables() {
+ String template =
+ "Project ${project} exceeds quota: maximum=${maximum}, available=${available}.";
+
+ String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 100L, 200L, null);
+
+ assertThat(result).isEqualTo("Project foo/bar exceeds quota: maximum=200, available=100.");
+ }
+
+ @Test
+ public void interpolateHandlesRepeatedVariables() {
+ String template =
+ "Project ${project}, project=${project}, available=${available}, maximum=${maximum}.";
+
+ String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 42L, 1337L, null);
+
+ assertThat(result).isEqualTo("Project foo/bar, project=foo/bar, available=42, maximum=1337.");
+ }
+
+ @Test
+ public void interpolateLeavesUnknownVariablesUntouched() {
+ String template = "Project ${project} exceeded quota ${unknown} with available=${available}.";
+
+ String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 10L, 20L, null);
+
+ assertThat(result).isEqualTo("Project foo/bar exceeded quota ${unknown} with available=10.");
+ }
+
+ @Test
+ public void interpolateWorksWithEmptyTemplate() {
+ String template = "";
+
+ String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 10L, 20L, null);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ public void interpolateWorksWhenNumbersAreZero() {
+ String template = "Project ${project} has maximum=${maximum} and available=${available}.";
+
+ String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 0L, 0L, null);
+
+ assertThat(result).isEqualTo("Project foo/bar has maximum=0 and available=0.");
+ }
+
+ @Test
+ public void interpolateRequestedWhenAvailable() {
+ String template =
+ "Project ${project} has maximum=${maximum} and available=${available}, but ${requested} was"
+ + " requested.";
+
+ String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 0L, 0L, 100L);
+
+ assertThat(result)
+ .isEqualTo("Project foo/bar has maximum=0 and available=0, but 100 was requested.");
+ }
+
+ @Test
+ public void interpolateRequestedToEmptyStringWhenNotAvailable() {
+ String template =
+ "Project ${project} has maximum=${maximum} and available=${available}, but ${requested} was"
+ + " requested.";
+
+ String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 0L, 0L, 100L);
+
+ assertThat(result)
+ .isEqualTo("Project foo/bar has maximum=0 and available=0, but 100 was requested.");
+ }
+}