ServiceTest.java [src/csip/test] Revision:   Date:
/*
 * $Id$
 *
 * This file is part of the Cloud Services Integration Platform (CSIP),
 * a Model-as-a-Service framework, API and application suite.
 *
 * 2012-2022, Olaf David and others, OMSLab, Colorado State University.
 *
 * OMSLab licenses this file to you under the MIT license.
 * See the LICENSE file in the project root for more information.
 */
package csip.test;

import csip.utils.Client;
import csip.utils.JSONUtils;
import java.io.File;
import java.io.FileFilter;
import java.io.FileReader;
import java.io.Reader;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import java.util.logging.StreamHandler;
import org.apache.commons.io.FileUtils;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONObject;
import org.skyscreamer.jsonassert.JSONAssert;

/**
 * Service Testing.
 *
 * @author odavid
 */
public class ServiceTest {

  static final String KEYSONLY = "keysonly";
  static final String KEYVALUE = "keyvalue";
  static final String KEYVALUEORDER = "keyvalueorder";
  static final String NONE = "none";

  static final int NO_WARMUP = 0;
  static final int WARMUP_AND_RUN = 1;
  static final int WARMUP_ONLY = 2;

  static final List<String> VALIDTESTING = Arrays.asList(KEYSONLY, KEYVALUE, KEYVALUEORDER, NONE);

  File folder;
  File[] files;
  String url;
  String testing;
  int concurrent;
  int workers;
  int timeout;
  int modelTimeout;
  int delay;
  int limit;
  int warmup;
  int warmupRuns;
  boolean downloadFiles;
  String logLevel = "WARNING";
  String config;
  boolean ignore = false;

  static final Logger log = Logger.getLogger("ServiceTest");


  static {
    log.setLevel(Level.SEVERE);
    SimpleFormatter fmt = new SimpleFormatter();
    StreamHandler sh = new StreamHandler(System.out, fmt);
    log.addHandler(sh);
  }


  private ServiceTest(File target) throws Exception {
    if (!target.exists()) {
      throw new IllegalArgumentException("Not a folder or file: " + target);
    }

    if (target.getName().endsWith("req.json") && target.isFile()) {
      folder = target.getParentFile();
      files = new File[]{target};
    } else if (target.isDirectory()) {
      folder = target;
      files = folder.listFiles(new FileFilter() {

        @Override
        public boolean accept(File pathname) {
          return pathname.getName().endsWith("-req.json");
        }
      });
      // sort the file names.
      Arrays.sort(files, new Comparator<File>() {
        @Override
        public int compare(File o1, File o2) {
          int n1 = extractNumber(o1.getName());
          int n2 = extractNumber(o2.getName());
          if ((n1 == 0) || (n2 == 0)) {
            return o1.compareTo(o2);
          } else {
            return n1 - n2;
          }
        }

        // to do - could make char s and char e configurable
        // and this would enable custom ways to extract a number 
        // from the file name for sorting

        private int extractNumber(String name) {
          int i = 0;
          try {
            int s = name.lastIndexOf('t') + 1;
            int e = name.lastIndexOf('-');
            if ((s < 0) || (e < 0)) {
              s = name.lastIndexOf('_') + 1;
              e = name.lastIndexOf('.');
            }
            String number = name.substring(s, e);
            i = Integer.parseInt(number);
          } catch (NumberFormatException nfe) {
            i = 0;
          }
          return i;
        }
      });
    } else {
      throw new IllegalArgumentException("Invalid argument: " + target);
    }

    // load the service testing settings.
    Properties p = loadProperties(folder);
    testing = p.getProperty("verify", NONE);  // strict, keys
    if (!VALIDTESTING.contains(testing)) {
      throw new RuntimeException("invalid testing method: " + testing);
    }

    ignore = Boolean.parseBoolean(p.getProperty("ignore", "false"));

    url = p.getProperty("url");
    if (url == null) {
      String host = p.getProperty("host");
      String path = p.getProperty("path");
      if (host == null && path == null) {
        throw new RuntimeException("'url' (or host/path) not found in " + new File(folder, "service.properties"));
      }
      url = host + path;
    }

    concurrent = Integer.parseInt(p.getProperty("concurrency", "1"));
    if (concurrent < 1) {
      concurrent = 1;
    }
    timeout = Integer.parseInt(p.getProperty("timeout", "3600"));
    if (timeout < 1000) {
      timeout = 1000;
    }
    modelTimeout = Integer.parseInt(p.getProperty("modelTimeout", "600000"));  // default is 10 minutes
    if (modelTimeout <= 0) {
      modelTimeout = 600000;
    }
    delay = Integer.parseInt(p.getProperty("delay", "0"));
    if (delay < 0) {
      delay = 0;
    }
    workers = Integer.parseInt(p.getProperty("workers", "1"));
    if (workers < 0) {
      workers = 1;
    }
    // warmup phase: 0-off, 1-on
    warmup = Integer.parseInt(p.getProperty("warmup", "0"));
    if (warmup < 0) {
      warmup = NO_WARMUP;
    }
    warmupRuns = Integer.parseInt(p.getProperty("warmupRuns", "4"));
    if (warmupRuns < 0) {
      warmupRuns = 4;
    }
    downloadFiles = Boolean.parseBoolean(p.getProperty("download_files", "true"));

    logLevel = p.getProperty("logLevel", "WARNING");
    if ((logLevel != null) && (logLevel.length() > 0)) {
      log.setLevel(Level.parse(logLevel));
    }
    config = p.getProperty("config", "service-conf.json");
    limit = Integer.parseInt(p.getProperty("limit", "-1"));
    limit = (limit < 1) ? files.length : limit;
  }


  /**
   * Execute the test.
   *
   * @return
   */
  private Results test() {
    final Results results = new Results();

    if (ignore) {
      results.incIgnored();
      return results;
    }

    try (Client client = new Client(timeout, log)) {

      // apply configuration
      if (config != null) {
        // changed with absolute name
        File conf = new File(config);
        if (!conf.exists()) {
          // relative name
          conf = new File(folder, config);
        }
        if (conf.exists()) {
          try {
            JSONObject o = new JSONObject(FileUtils.readFileToString(conf, "UTF-8"));
            client.doPUT(getBaseURLContext(url) + "/c/conf", o);
          } catch (Exception E) {
            log.warning("Failed to load config: " + conf);
          }
        } else {
          // if there is no config... then assume service is pre-configured...
        }
      }

      int maxFiles = limit;

      final CountDownLatch latch = new CountDownLatch(maxFiles);
      final CountDownLatch warmupLatch = new CountDownLatch(workers * warmupRuns);
      final ConcurrentLinkedQueue<File> fileQueue = new ConcurrentLinkedQueue<>();
      final ConcurrentLinkedQueue<File> warmups = new ConcurrentLinkedQueue<>();
      for (int i = 0; i < maxFiles; i++) {
        if ((i < workers * warmupRuns) && (warmup != NO_WARMUP)) {
          warmups.add(files[i]);
        }
        fileQueue.add(files[i]);
      }

      ExecutorService warmupExecutor = null;
      final ExecutorService executor = Executors.newFixedThreadPool(concurrent, new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
          return new Thread(r, "exeuctor-thread");
        }
      });

//        final ExecutorService executor = new ThreadPoolExecutor(
//                concurrent, concurrent, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()) {
//                    @Override
//                    protected void afterExecute(Runnable r, Throwable t) {
//                        super.afterExecute(r, t);
//                        if (t != null) {
//                            System.out.println("Perform exception handler logic");
//                        }
//                        final File file = fileQueue.poll();
//                        if (file != null) {
//                            if (delay > 0) {
//                                try {
//                                    System.out.println("THIS WORKER SLEEP FOR:" + delay);
//                                    Thread.sleep(delay);
//                                } catch (InterruptedException ex) {
//                                    System.out.println("Interruption during sleep - non-init!!!");
//                                }
//                            }
//                            // keep going
//                            submit(new Callable<Void>() {
//                                @Override
//                                public Void call() {
//                                    return t(file, results, client, latch);
//                                }
//                            });
//                        }
//                        else
//                        {
//                            System.out.println("THE FILE IS NULL - THIS SHOULD NOT HAPPEN UNTIL END, filequeue-size=" + fileQueue.size());
//                        }
//                    }
//                };
      // submit the first run twice as a "warmup" across the workers
      if (warmup != NO_WARMUP) {
        final Results warmupResults = new Results();
        warmupExecutor = Executors.newFixedThreadPool(workers * warmupRuns, new ThreadFactory() {
          @Override
          public Thread newThread(Runnable r) {
            return new Thread(r, "warmup-thread");
          }
        });

        for (int i = 0; i < (workers * warmupRuns); i++) {
          final File file = warmups.poll();
          log.info("SUBMITTING WARM UP RUN=" + file);
          warmupExecutor.submit(new Callable<Void>() {
            @Override
            public Void call() {
              return t(file, warmupResults, client, warmupLatch);
            }
          });
        }
      }

      // submit the initial runs.
      if (warmup != WARMUP_ONLY) {
        //        for (int i = 0; i < Math.min(concurrent, maxFiles); i++) {
        for (int i = 0; i < maxFiles; i++) {
          if (delay > 0) {
            try {
              Thread.sleep(delay);
            } catch (InterruptedException ex) {
              log.warning("Interrupted during sleep- init runs!");
            }
          }

          final File file = fileQueue.poll();
          log.info("SUBMITTING TO PROCESS FILE=" + file);
          executor.submit(new Callable<Void>() {
            @Override
            public Void call() {
              return t(file, results, client, latch);
            }
          });
        }
      }
      try {
        if (warmup != WARMUP_ONLY) {
          latch.await();
        }
        if (warmup != NO_WARMUP) {
          warmupLatch.await();
        }
      } catch (InterruptedException ex) {
        // whatever
      }
      executor.shutdown();
      if (warmup != NO_WARMUP) {
        warmupExecutor.shutdown();
      }
    }
    return results;
  }


  /**
   *
   * @param reqFile
   * @param results
   * @param client
   * @param latch
   * @return
   */
  private Void t(File reqFile, Results results, Client client, CountDownLatch latch) {
    try {
      if (delay > 0) {
        try {
          Thread.sleep(delay);
        } catch (InterruptedException ex) {
          log.warning("Interrupted during sleep: pre-run-sleep");
        }
      }

      File res = getResFile(reqFile);
      // check if there is a async result with the status "Running"
      if (res.exists()) {
        res.delete();

//                // ASYNC PART
//                // check if the prev run was running.
//                JSONObject prev_json = new JSONObject(FileUtils.readFileToString(res));
//                if (JSONUtils.getStatus(prev_json).equals("Finished")) {
//                    log.warning("Skipped (Finished)" + " " + reqFile + "... ");
//                    results.incSkipped();
//                    log.info("EXITING test run,,, latch count-down?");
//                    return null;
//                }
//
//                if (JSONUtils.getStatus(prev_json).equals("Running") || JSONUtils.getStatus(prev_json).equals("Submitted")) {
//                    // it is running, need to check again.
//                    String q_url = getBaseURLContext(url) + "/q/" + JSONUtils.getSID(prev_json);
//
//                    // get the new
//                    client.doGET(q_url, res);
//
//                    // check the status again
//                    JSONObject this_json = new JSONObject(FileUtils.readFileToString(res));
//                    String status = JSONUtils.getStatus(this_json);
//                    log.info(status + " " + reqFile + "... ");
//                    if (status.equals("Finished")) {
//
//                        // dowload
//                        download(this_json, getResFolder(reqFile), client);
//
//                        if (testing.equals(NONE)) {
//                            results.incSuccess();
//                            log.info("ASYNC EXIT NONE");
//                            return null;
//                        }
//                        // test
//                        runtest(reqFile, this_json);
//                    }
//                    results.incSuccess();
//                    log.info("ASYNC EXIT END");
//                    return null;
//                }
      }

      JSONObject req_json = new JSONObject(FileUtils.readFileToString(reqFile, "UTF-8"));
      boolean isAsync = JSONUtils.isAsync(req_json);

      // input
      File inp = getInpFolder(reqFile);
      File[] attachments = (inp != null) ? inp.listFiles() : new File[0];

      // delete output if exist.
      FileUtils.deleteDirectory(getResFolderName(reqFile));

      // run the service
      JSONObject response = client.doPOST(url, req_json, attachments, (Map<String, String>) null);
      FileUtils.writeStringToFile(getResFile(reqFile), response.toString(2).replace("\\/", "/"), "UTF-8");

      // print the error if failed.
      if (JSONUtils.getStatus(response).equals("Failed")) {
        JSONObject respose_metainfo = response.getJSONObject("metainfo");
        throw new Exception(respose_metainfo.getString("error"));
      }

      if (isAsync) {
        results.incSuccess();
        log.info("THREAD EXECUTOR TEST SUCCESS:" + reqFile);
        return null;
      }

      if (!JSONUtils.hasResult(response)) {
        throw new Exception("No 'result' in response");
      }

      if (downloadFiles) {
        download(response, getResFolder(reqFile), client);
      }

      if (testing.equals(NONE)) {
        results.incSuccess();
        log.info("EXITING test run,,, latch count-down?");
        return null;
      }

      runtest(reqFile, response);
      results.incSuccess();
    } catch (Exception | Error E) {
      System.out.println("  Failed: " + reqFile);
      log.warning("ERROR-" + E.toString());
      for (StackTraceElement ste : E.getStackTrace()) {
        log.warning(ste.getClassName() + ":" + ste.getMethodName() + ":" + ste.getLineNumber() + "---" + ste.toString());
      }
      System.out.println(E.getMessage());
      results.incFailed();
    } finally {
      log.info("THREAD EXECUTOR COUNT DOWN AFTER:" + reqFile);
      latch.countDown();
    }
    return null;
  }


  private void runtest(File reqFile, JSONObject response) throws Exception {
    JSONArray response_result = response.getJSONArray("result");
    // all real checking starts here.
    // golden response
    JSONObject golJson = new JSONObject(FileUtils.readFileToString(getResponse(reqFile), "UTF-8"));

    // check output files
    JSONArray response_golden = golJson.getJSONArray("result");
    Map<String, JSONObject> rmap = JSONUtils.preprocess(response_result);

    Map<String, JSONObject> golmap = JSONUtils.preprocess(response_golden);
    for (String key : rmap.keySet()) {
      JSONObject val = rmap.get(key);
      String url = val.getString("value");
      if (url.startsWith("http") && url.endsWith(key)) {
        response_result.remove(val);
        golmap.remove(golmap.get(key));
      }
    }

    File out = getRefFolder(reqFile);
    if (out != null) {
      File[] outFiles = out.listFiles();
      for (File file : outFiles) {
        String f = file.getName();
        if (!rmap.containsKey(f)) {
          throw new Exception("Expecting file in result: " + f);
        }
        // TODO compare the output files.
      }
    }

    switch (testing) {
      case KEYVALUE:
        compareStrict(response_result, response_golden);
        break;
      case KEYSONLY:
        compareKeys(response_result, response_golden);
        break;
      default:
        throw new IllegalArgumentException(testing);
    }
  }

  /**
   *
   */
  public static class Results {

    int succeeded = 0;
    int failed = 0;
    int skipped = 0;
    int ignored = 0;


    void add(Results other) {
      succeeded += other.getSucceeded();
      failed += other.getFailed();
      skipped += other.getSkipped();
      ignored += other.getIgnored();
    }


    public int getTotal() {
      return succeeded + failed + skipped + ignored;
    }


    public int getFailed() {
      return failed;
    }


    public int getSkipped() {
      return skipped;
    }


    public int getSucceeded() {
      return succeeded;
    }


    public int getIgnored() {
      return ignored;
    }


    synchronized void incSuccess() {
      succeeded++;
    }


    synchronized void incFailed() {
      failed++;
    }


    synchronized void incSkipped() {
      skipped++;
    }


    synchronized void incIgnored() {
      ignored++;
    }


    @Override
    public String toString() {
      return "=================" + "\n"
          + " Total:     " + getTotal() + "\n"
          + " Failed:    " + getFailed() + "\n"
          + " Succeeded: " + getSucceeded() + "\n"
          + " Skipped:   " + getSkipped() + "\n"
          + " Ignored:   " + getIgnored() + "\n";
    }

  }


  static void download(JSONObject resp, File resultFolder, Client client) throws Exception {
    JSONArray arr = resp.getJSONArray("result");
    // download files using the 'q' service.
    Map<String, JSONObject> rmap = JSONUtils.preprocess(arr);
    for (String key : rmap.keySet()) {
      JSONObject val = rmap.get(key);
      String url = val.getString("value");
      if (url == null) {
        throw new Exception("Expecting file in result: " + key);
      }
      if (url.startsWith("http") && url.contains("/q/") && url.endsWith(key)) {
        if (!resultFolder.exists()) {
          resultFolder.mkdirs();
        }
        File outFile = new File(resultFolder, key);
        client.doGET(url, outFile);
        if (!outFile.exists()) {
          throw new Exception("Missing output file: " + key);
        }
      }
    }
  }


  static File getInpFolder(File request) {
    return getFolder(request, "-req");
  }


  static File getRefFolder(File request) {
    return getFolder(request, "-ref");
  }


  static File getResFolderName(File request) {
    String name = request.getPath();
    return new File(name.replace("-req.json", "-res"));
  }


  static File getResponse(File request) {
    String name = request.getPath();
    if (!name.endsWith("-req.json")) {
      return null;
    }
    File response = new File(name.replace("-req.json", "-res.json"));
    if (!response.exists() || !request.canRead() || !response.canRead()) {
      return null;
    }
    return response;
  }


  static File getResFile(File request) {
    String name = request.getPath();
    File res = new File(name.replace("-req.json", "-res.json"));
    return res;
  }


  static File getResFolder(File request) {
    String name = request.getPath();
    File res = new File(name.replace("-req.json", "-res"));
    return res;
  }


  static File getFolder(File request, String postfix) {
    String name = request.getPath();
    if (!name.endsWith("-req.json")) {
      return null;
    }
    File inp = new File(name.replace("-req.json", postfix));
    if (!inp.exists() || !inp.canRead() || !inp.isDirectory()) {
      return null;
    }
    return inp;
  }


  static void compareKeys(JSONArray res, JSONArray gol) throws Exception {
    Map<String, JSONObject> preq = JSONUtils.preprocess(res);
    Map<String, JSONObject> pgol = JSONUtils.preprocess(gol);
    for (String key : pgol.keySet()) {
      if (!preq.containsKey(key)) {
        throw new Exception("Missing key in result:" + key);
      }
      preq.remove(key);
    }
    if (!preq.keySet().isEmpty()) {
      throw new Exception("Extra key(s) in result:" + preq.keySet().toString());
    }
  }


  static void compareStrict(JSONArray res, JSONArray gol) throws Exception {
    JSONAssert.assertEquals(gol.toString(), res.toString(), false);
  }


  static Properties loadProperties(File folder) throws Exception {
    Properties p = new Properties();
    File props = new File(folder.getParentFile(), "service.properties");
    if (props.exists()) {
      try (Reader is = new FileReader(props)) {
        p.load(is);
      }
    }
    props = new File(folder, "service.properties");
    if (!props.exists()) {
      throw new RuntimeException("Not found: " + props);
    }

    try (Reader is = new FileReader(props)) {
      p.load(is);
    }
    return p;
  }


  static String getBaseURLContext(String url) {
    // this is the first part (host + port)
    String host = url.substring(0, url.indexOf("/", url.indexOf("://") + 3));
    // this is the second part (context)
    String context = url.substring(host.length() + 1, url.indexOf("/", host.length() + 1));

    return host + "/" + context;
  }


  public static Results run(String target) throws Exception {
    return new ServiceTest(new File(target)).test();
  }


  public static Results run(String[] targets) throws Exception {
    Results summary = new Results();
    for (String target : targets) {
      Results result = new ServiceTest(new File(target)).test();
      summary.add(result);
    }
    return summary;
  }


  public static void main(String[] args) throws Exception {
    System.out.println(run(args));
  }
}