1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli;
7   
8   import static org.assertj.core.api.Assertions.assertThat;
9   import static org.junit.jupiter.api.Assertions.assertAll;
10  import static org.junit.jupiter.api.Assertions.assertEquals;
11  import static org.junit.jupiter.api.Assertions.assertNull;
12  
13  import org.junit.jupiter.api.Test;
14  import org.junit.jupiter.api.parallel.Execution;
15  import org.junit.jupiter.api.parallel.ExecutionMode;
16  import org.junit.jupiter.params.ParameterizedTest;
17  import org.junit.jupiter.params.provider.Arguments;
18  import org.junit.jupiter.params.provider.MethodSource;
19  
20  import java.io.OutputStream;
21  import java.io.PrintStream;
22  import java.util.LinkedList;
23  import java.util.List;
24  import java.util.stream.Stream;
25  
26  import dev.metaschema.cli.processor.ExitCode;
27  import dev.metaschema.cli.processor.ExitStatus;
28  import edu.umd.cs.findbugs.annotations.NonNull;
29  import nl.altindag.log.LogCaptor;
30  
31  /**
32   * Unit test for simple CLI.
33   */
34  @Execution(value = ExecutionMode.SAME_THREAD, reason = "Log capturing needs to be single threaded")
35  public class CLITest {
36    private static final ExitCode NO_EXCEPTION_CLASS = null;
37  
38    /**
39     * A PrintStream that discards all output, used to suppress CLI console output
40     * during tests.
41     */
42    @SuppressWarnings("resource")
43    private static final PrintStream NULL_STREAM = new PrintStream(new OutputStream() {
44      @Override
45      public void write(int b) {
46        // discard
47      }
48  
49      @Override
50      public void write(byte[] b, int off, int len) {
51        // discard
52      }
53    });
54  
55    void evaluateResult(@NonNull ExitStatus status, @NonNull ExitCode expectedCode, @NonNull String[] args) {
56      status.generateMessage(true);
57      Throwable thrown = status.getThrowable();
58      assertAll(
59          () -> assertEquals(expectedCode, status.getExitCode(),
60              () -> buildExitCodeMismatchMessage(status, expectedCode, thrown, args)),
61          () -> assertNull(thrown,
62              () -> buildUnexpectedThrowableMessage(thrown, args)));
63    }
64  
65    void evaluateResult(@NonNull ExitStatus status, @NonNull ExitCode expectedCode,
66        @NonNull Class<? extends Throwable> thrownClass, @NonNull String[] args) {
67      Throwable thrown = status.getThrowable();
68      assertAll(
69          () -> assertEquals(expectedCode, status.getExitCode(),
70              () -> buildExitCodeMismatchMessage(status, expectedCode, thrown, args)),
71          () -> assertEquals(thrownClass, thrown == null ? null : thrown.getClass(),
72              () -> buildThrowableMismatchMessage(thrownClass, thrown, args)));
73    }
74  
75    private static String buildExitCodeMismatchMessage(@NonNull ExitStatus status, @NonNull ExitCode expectedCode,
76        Throwable thrown, @NonNull String[] args) {
77      StringBuilder sb = new StringBuilder();
78      sb.append("exit code mismatch: expected <").append(expectedCode).append("> but was <")
79          .append(status.getExitCode()).append(">");
80      sb.append("\nCommand args: ").append(String.join(" ", args));
81      if (status.getMessage() != null) {
82        sb.append("\nStatus message: ").append(status.getMessage());
83      }
84      if (thrown != null) {
85        sb.append("\nThrowable: ").append(thrown.getClass().getName()).append(": ").append(thrown.getMessage());
86        sb.append("\nStack trace:\n").append(getStackTraceAsString(thrown));
87      }
88      return sb.toString();
89    }
90  
91    private static String buildUnexpectedThrowableMessage(Throwable thrown, @NonNull String[] args) {
92      if (thrown == null) {
93        return "expected null Throwable";
94      }
95      StringBuilder sb = new StringBuilder();
96      sb.append("expected null Throwable but got: ").append(thrown.getClass().getName())
97          .append(": ").append(thrown.getMessage());
98      sb.append("\nCommand args: ").append(String.join(" ", args));
99      sb.append("\nStack trace:\n").append(getStackTraceAsString(thrown));
100     return sb.toString();
101   }
102 
103   private static String buildThrowableMismatchMessage(Class<? extends Throwable> expectedClass, Throwable thrown,
104       @NonNull String[] args) {
105     StringBuilder sb = new StringBuilder();
106     sb.append("expected Throwable mismatch: expected <")
107         .append(expectedClass == null ? "null" : expectedClass.getName())
108         .append("> but was <")
109         .append(thrown == null ? "null" : thrown.getClass().getName())
110         .append(">");
111     sb.append("\nCommand args: ").append(String.join(" ", args));
112     if (thrown != null) {
113       sb.append("\nMessage: ").append(thrown.getMessage());
114       sb.append("\nStack trace:\n").append(getStackTraceAsString(thrown));
115     }
116     return sb.toString();
117   }
118 
119   private static String getStackTraceAsString(Throwable throwable) {
120     java.io.StringWriter sw = new java.io.StringWriter();
121     throwable.printStackTrace(new java.io.PrintWriter(sw));
122     return sw.toString();
123   }
124 
125   private static Stream<Arguments> providesValues() {
126     List<Arguments> values = new LinkedList<>() {
127       {
128         add(Arguments.of(new String[] {}, ExitCode.INVALID_COMMAND,
129             NO_EXCEPTION_CLASS));
130         add(Arguments.of(new String[] { "-h" }, ExitCode.OK, NO_EXCEPTION_CLASS));
131         add(Arguments.of(new String[] { "generate-schema", "--help" }, ExitCode.OK,
132             NO_EXCEPTION_CLASS));
133         add(Arguments.of(new String[] { "generate-diagram", "--help" }, ExitCode.OK,
134             NO_EXCEPTION_CLASS));
135         add(Arguments.of(new String[] { "validate", "--help" }, ExitCode.OK,
136             NO_EXCEPTION_CLASS));
137         add(Arguments.of(new String[] { "validate-content", "--help" }, ExitCode.OK,
138             NO_EXCEPTION_CLASS));
139         add(Arguments.of(new String[] { "convert", "--help" }, ExitCode.OK,
140             NO_EXCEPTION_CLASS));
141         add(Arguments.of(new String[] { "metapath", "list-functions", "--help" }, ExitCode.OK,
142             NO_EXCEPTION_CLASS));
143         add(Arguments.of(new String[] { "metapath", "eval", "--help" }, ExitCode.OK,
144             NO_EXCEPTION_CLASS));
145         add(Arguments.of(
146             new String[] { "validate",
147                 "../databind/src/test/resources/metaschema/fields_with_flags/metaschema.xml"
148             },
149             ExitCode.OK, NO_EXCEPTION_CLASS));
150         add(Arguments.of(
151             new String[] { "generate-schema", "--overwrite", "--as",
152                 "JSON",
153                 "../databind/src/test/resources/metaschema/fields_with_flags/metaschema.xml",
154                 "target/schema-test.json" },
155             ExitCode.OK, NO_EXCEPTION_CLASS));
156         add(Arguments.of(
157             new String[] { "validate-content", "--as=xml",
158                 "-m=../databind/src/test/resources/metaschema/bad_index-has-key/metaschema.xml",
159                 "../databind/src/test/resources/metaschema/bad_index-has-key/example.xml",
160                 "--show-stack-trace" },
161             ExitCode.FAIL, NO_EXCEPTION_CLASS));
162         add(Arguments.of(
163             new String[] { "validate-content", "--as=json",
164                 "-m=../databind/src/test/resources/metaschema/bad_index-has-key/metaschema.xml",
165                 "../databind/src/test/resources/metaschema/bad_index-has-key/example.json", "--show-stack-trace" },
166             ExitCode.FAIL, NO_EXCEPTION_CLASS));
167         add(Arguments.of(
168             new String[] { "validate",
169                 "../databind/src/test/resources/metaschema/simple/metaschema.xml",
170                 "--show-stack-trace" },
171             ExitCode.OK, NO_EXCEPTION_CLASS));
172         add(Arguments.of(
173             new String[] { "generate-schema",
174                 "../databind/src/test/resources/metaschema/simple/metaschema.xml",
175                 "--as", "xml",
176             },
177             ExitCode.OK, NO_EXCEPTION_CLASS));
178         add(Arguments.of(
179             new String[] { "generate-schema",
180                 "../databind/src/test/resources/metaschema/simple/metaschema.xml",
181                 "--as", "json",
182             },
183             ExitCode.OK, NO_EXCEPTION_CLASS));
184         add(Arguments.of(
185             new String[] { "generate-diagram",
186                 "../databind/src/test/resources/metaschema/simple/metaschema.xml"
187             },
188             ExitCode.OK, NO_EXCEPTION_CLASS));
189         add(Arguments.of(
190             new String[] { "validate-content",
191                 "-m",
192                 "../databind/src/test/resources/metaschema/simple/metaschema.xml",
193                 "../databind/src/test/resources/metaschema/simple/example.json",
194                 "--as=json"
195             },
196             ExitCode.OK, NO_EXCEPTION_CLASS));
197         add(Arguments.of(
198             new String[] { "validate-content",
199                 "-m",
200                 "../databind/src/test/resources/metaschema/simple/metaschema.xml",
201                 "../databind/src/test/resources/metaschema/simple/example.xml",
202                 "--as=xml"
203             },
204             ExitCode.OK, NO_EXCEPTION_CLASS));
205         add(Arguments.of(
206             new String[] { "validate-content",
207                 "-m",
208                 "../databind/src/test/resources/metaschema/simple/metaschema.xml",
209                 "https://bad.domain.example.net/example.xml",
210                 "--as=xml"
211             },
212             ExitCode.IO_ERROR, java.net.UnknownHostException.class));
213         add(Arguments.of(
214             new String[] { "validate-content",
215                 "-m",
216                 "../databind/src/test/resources/metaschema/simple/metaschema.xml",
217                 "https://github.com/no-example.xml",
218                 "--as=xml"
219             },
220             ExitCode.IO_ERROR, java.io.FileNotFoundException.class));
221         add(Arguments.of(
222             new String[] { "validate-content",
223                 "-m",
224                 "src/test/resources/content/schema-validation-module.xml",
225                 "src/test/resources/content/schema-validation-module-missing-required.xml",
226                 "--as=xml"
227             },
228             // fail due to schema validation issue
229             ExitCode.FAIL, NO_EXCEPTION_CLASS));
230         add(Arguments.of(
231             new String[] { "validate-content",
232                 "-m",
233                 "src/test/resources/content/schema-validation-module.xml",
234                 "src/test/resources/content/schema-validation-module-missing-required.xml",
235                 "--as=xml",
236                 "--disable-schema-validation"
237             },
238             // fail due to missing element during parsing
239             ExitCode.FAIL, NO_EXCEPTION_CLASS));
240         add(Arguments.of(
241             new String[] { "validate-content",
242                 "-m",
243                 "src/test/resources/content/schema-validation-module.xml",
244                 "src/test/resources/content/schema-validation-module-missing-required.xml",
245                 "--as=xml",
246                 "--disable-schema-validation",
247                 "--disable-constraint-validation"
248             },
249             ExitCode.OK, NO_EXCEPTION_CLASS));
250         add(Arguments.of(
251             new String[] { "metapath", "list-functions" },
252             ExitCode.OK, NO_EXCEPTION_CLASS));
253         add(Arguments.of(
254             new String[] { "convert",
255                 "-m",
256                 "../core/metaschema/schema/metaschema/metaschema-module-metaschema.xml",
257                 "--to=yaml",
258                 "../core/metaschema/schema/metaschema/metaschema-module-metaschema.xml",
259             },
260             ExitCode.OK, NO_EXCEPTION_CLASS));
261         // Test markup-line datatype validation with YAML module
262         add(Arguments.of(
263             new String[] { "validate-content",
264                 "-m",
265                 "src/test/resources/content/test-markup-line-module.yaml",
266                 "src/test/resources/content/test-markup-line-content.json",
267                 "--as=json"
268             },
269             ExitCode.OK, NO_EXCEPTION_CLASS));
270       }
271     };
272     return values.stream();
273   }
274 
275   @ParameterizedTest
276   @MethodSource("providesValues")
277   void testAllCommands(@NonNull String[] args, @NonNull ExitCode expectedExitCode,
278       Class<? extends Throwable> expectedThrownClass) {
279     String[] defaultArgs = { "--show-stack-trace" };
280     String[] fullArgs = Stream.of(args, defaultArgs).flatMap(Stream::of)
281         .toArray(String[]::new);
282     if (expectedThrownClass == null) {
283       evaluateResult(CLI.runCli(NULL_STREAM, fullArgs), expectedExitCode, fullArgs);
284     } else {
285       evaluateResult(CLI.runCli(NULL_STREAM, fullArgs), expectedExitCode, expectedThrownClass, fullArgs);
286     }
287   }
288 
289   @Test
290   void testValidateContent() {
291     try (LogCaptor captor = LogCaptor.forRoot()) {
292       String[] cliArgs = { "validate-content",
293           "-m",
294           "src/test/resources/content/215-module.xml",
295           "src/test/resources/content/215.xml",
296           "--disable-schema-validation"
297       };
298       CLI.runCli(NULL_STREAM, cliArgs);
299       assertThat(captor.getErrorLogs().toString())
300           .contains("expect-default-non-zero: Expect constraint '. > 0' did not match the data",
301               "expect-custom-non-zero: No default message, custom error message for expect-custom-non-zero constraint.",
302               "matches-default-regex-letters-only: Value '1' did not match the pattern",
303               "matches-custom-regex-letters-only: No default message, custom error message for" +
304                   " matches-custom-regex-letters-only constraint.",
305               "cardinality-default-two-minimum: The cardinality '1' is below the required minimum '2' for items" +
306                   " matching",
307               "index-items-default: Index 'index-items-default' has duplicate key for items",
308               "index-items-custom: No default message, custom error message for index-item-custom.",
309               "is-unique-default: Unique constraint violation at paths",
310               "is-unique-custom: No default message, custom error message for is-unique-custom.",
311               "index-has-key-default: Key reference [2] not found in index 'index-items-default' for item",
312               "index-has-key-custom: No default message, custom error message for index-has-key-custom.");
313     }
314   }
315 
316   @Test
317   void testValidateConstraints() {
318     try (LogCaptor captor = LogCaptor.forRoot()) {
319       String[] cliArgs = { "validate",
320           "src/test/resources/content/constraint-example.xml",
321           "-c",
322           "src/test/resources/content/constraint-constraints.xml",
323           "--disable-schema-validation",
324       };
325       CLI.runCli(NULL_STREAM, cliArgs);
326       assertThat(captor.getErrorLogs().toString())
327           .contains("This constraint SHOULD be violated if test passes.");
328     }
329   }
330 }