lodash#zip TypeScript Examples

The following examples show how to use lodash#zip. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: helpers.ts    From webapp with MIT License 6 votes vote down vote up
traverseLockedBalances = async (
  contract: string,
  owner: string,
  expectedCount: number
): Promise<LockedBalance[]> => {
  const storeContract = buildLiquidityProtectionStoreContract(contract, web3);
  let lockedBalances: LockedBalance[] = [];

  const scopeRange = 5;
  for (let i = 0; i < 10; i++) {
    const startIndex = i * scopeRange;
    const endIndex = startIndex + scopeRange;

    const lockedBalanceRes = await storeContract.methods
      .lockedBalanceRange(owner, String(startIndex), String(endIndex))
      .call();

    const bntWeis = lockedBalanceRes["0"];
    const expirys = lockedBalanceRes["1"];

    const zipped = zip(bntWeis, expirys);
    const withIndex = zipped.map(
      ([bntWei, expiry], index) =>
        ({
          amountWei: bntWei,
          expirationTime: Number(expiry),
          index: index + startIndex
        } as LockedBalance)
    );
    lockedBalances = lockedBalances.concat(withIndex);
    if (lockedBalances.length >= expectedCount) break;
  }

  return lockedBalances;
}
Example #2
Source File: pools.ts    From webapp with MIT License 6 votes vote down vote up
zipAnchorAndConverters = (
  anchorAddresses: string[],
  converterAddresses: string[]
): ConverterAndAnchor[] => {
  if (anchorAddresses.length !== converterAddresses.length)
    throw new Error(
      "was expecting as many anchor addresses as converter addresses"
    );
  const zipped = zip(anchorAddresses, converterAddresses) as [string, string][];
  return zipped.map(([anchorAddress, converterAddress]) => ({
    anchorAddress: anchorAddress!.toLowerCase(),
    converterAddress: converterAddress!.toLowerCase()
  }));
}
Example #3
Source File: block.ts    From ironfish with Mozilla Public License 2.0 6 votes vote down vote up
equals(block1: Block, block2: Block): boolean {
    if (!this.blockHeaderSerde.equals(block1.header, block2.header)) {
      return false
    }

    if (block1.transactions.length !== block2.transactions.length) {
      return false
    }

    for (const [transaction1, transaction2] of zip(block1.transactions, block2.transactions)) {
      if (
        !transaction1 ||
        !transaction2 ||
        !this.strategy.transactionSerde.equals(transaction1, transaction2)
      ) {
        return false
      }
    }

    return true
  }
Example #4
Source File: Uint8ArraySerde.ts    From ironfish with Mozilla Public License 2.0 6 votes vote down vote up
equals(element1: Uint8Array, element2: Uint8Array): boolean {
    if (element1.length !== this.size) {
      throw new Error('Attempting to compare inappropriately sized array')
    }
    if (element1.length !== element2.length) {
      return false
    }
    for (const [first, second] of zip(element1, element2)) {
      if (first !== second) {
        return false
      }
    }
    return true
  }
Example #5
Source File: safeZip.ts    From nextclade with MIT License 6 votes vote down vote up
export function safeZip<T, U>(first: T[], second: U[]) {
  const firstLen = first.length
  const secondLen = second.length
  if (first.length !== second.length) {
    throw new Error(
      `safeZip: expected zipped arrays to be of equal length, but got arrays of lengths ${firstLen} and ${secondLen}`,
    )
  }

  return zip(first, second) as [T, U][]
}
Example #6
Source File: safeZip.ts    From nextclade with MIT License 6 votes vote down vote up
export function safeZip3<T, U, V>(first: T[], second: U[], third: V[]) {
  const firstLen = first.length
  const secondLen = second.length
  const thirdLen = third.length
  if (first.length !== second.length || second.length !== third.length) {
    throw new Error(
      `safeZip: expected zipped arrays to be of equal length, but got arrays of lengths ${firstLen}, ${secondLen}, ${thirdLen}`,
    )
  }

  return zip(first, second, third) as [T, U, V][]
}
Example #7
Source File: Input.story.tsx    From grafana-chinese with Apache License 2.0 6 votes vote down vote up
getKnobs = () => {
  return {
    validation: text('Validation regex (will do a partial match if you do not anchor it)', ''),
    validationErrorMessage: text('Validation error message', 'Input not valid'),
    validationEvent: select(
      'Validation event',
      fromPairs(zip(Object.keys(EventsWithValidation), Object.values(EventsWithValidation))),
      EventsWithValidation.onBlur
    ),
  };
}
Example #8
Source File: index.ts    From webapp with MIT License 5 votes vote down vote up
@action async fetchPoolPrograms(
    rewardsStoreContract?: string
  ): Promise<PoolProgram[]> {
    if (this.poolPrograms) {
      return this.poolPrograms;
    }

    const storeContract =
      rewardsStoreContract || (await this.contract.methods.store().call());
    try {
      const store = buildStakingRewardsStoreContract(storeContract);
      const result = await store.methods.poolPrograms().call();

      const poolPrograms: PoolProgram[] = [];

      for (let i = 0; i < result[0].length; i++) {
        const reserveTokens = result[4][i];
        const rewardShares = result[5][i];
        const reservesTuple = zip(reserveTokens, rewardShares) as [
          string,
          string
        ][];
        const reserves: RewardShare[] = reservesTuple.map(
          ([reserveId, rewardShare]) => ({ reserveId, rewardShare })
        );

        poolPrograms.push({
          poolToken: result[0][i],
          startTimes: result[1][i],
          endTimes: result[2][i],
          rewardRate: result[3][i],
          reserves
        });
      }
      this.setPoolPrograms(poolPrograms);

      return poolPrograms;
    } catch (e) {
      throw new Error(
        `Failed fetching pool programs ${e.message} ${storeContract}`
      );
    }
  }
Example #9
Source File: buildTokenList.ts    From rewarder-list with GNU Affero General Public License v3.0 4 votes vote down vote up
buildTokenList = async (network: Network): Promise<void> => {
  const provider = makeProvider(network);

  const dir = `${__dirname}/../../data/${network}`;
  const lists = JSON.parse(
    (await fs.readFile(".tmp.token-list.json")).toString()
  ) as TokenList[];

  const rewarderList = JSON.parse(
    (await fs.readFile(`${dir}/rewarder-list.json`)).toString()
  ) as RewarderInfo[];
  const allRewarders = JSON.parse(
    (await fs.readFile(`${dir}/all-rewarders.json`)).toString()
  ) as Record<string, RewarderMeta>;

  const allMints = uniq([
    ...rewarderList
      .map((rwl) => rwl.redeemer?.underlyingToken)
      .filter((x): x is string => !!x),
    ...Object.values(allRewarders).map((r) => r.rewardsToken.mint),
    ...Object.keys(
      JSON.parse(
        (await fs.readFile(`${dir}/rewarders-by-mint.json`)).toString()
      ) as Record<string, unknown>
    ),
  ]).map((x) => new PublicKey(x));

  const allTokens = lists.flatMap((l) => l.tokens);

  const missingMints: PublicKey[] = [];
  const tokenListTokens = allMints
    .map((mint) => {
      const info = allTokens.find(
        (tok) =>
          tok.chainId === networkToChainId(network) &&
          tok.address === mint.toString()
      );
      if (info) {
        return info;
      }
      missingMints.push(mint);
      return null;
    })
    .filter((x): x is TokenInfo => !!x);

  const iouTokens = rewarderList
    .filter((rwl) => !!rwl.redeemer?.underlyingToken)
    .map((rwl) => {
      const underlyingStr = rwl.redeemer?.underlyingToken;
      const real = allRewarders[rwl.address];
      if (!real || !underlyingStr) {
        return null;
      }
      const redemptionInfo = tokenListTokens.find(
        (tok) => tok.address === real.rewardsToken.mint
      );
      if (redemptionInfo && rwl.redeemer?.method !== "quarry-redeemer") {
        return redemptionInfo;
      }
      const underlyingInfo = tokenListTokens.find(
        (tok) => tok.address === underlyingStr
      );
      if (!underlyingInfo) {
        return null;
      }
      return makeIOUTokenInfo(
        new PublicKey(real.rewardsToken.mint),
        underlyingInfo
      );
    })
    .filter(exists);
  console.log(`Found ${iouTokens.length} Quarry Redeemer IOU tokens`);

  const underlyingTokens = tokenListTokens
    .flatMap((tok) => {
      return (
        tok.extensions?.underlyingTokens?.map((ut) => {
          return allTokens.find(
            (t) =>
              t.address === ut.toString() &&
              t.chainId === networkToChainId(network)
          );
        }) ?? []
      );
    })
    .filter((t): t is TokenInfo => !!t);

  // check for all replicas that have a quarry
  const replicaMappings = (
    await Promise.all(
      allMints.map(async (mint) => {
        const [replicaMint] = await findReplicaMintAddress({
          primaryMint: mint,
        });
        return { replicaMint, underlyingMint: mint };
      })
    )
  ).filter((rm) => allMints.find((m) => m.equals(rm.replicaMint)));

  const missingReplicaMappings: {
    replicaMint: PublicKey;
    underlyingMint: PublicKey;
  }[] = [];
  const tokenListReplicas = missingMints
    .map((replicaMint): TokenInfo | null => {
      const replicaMapping = replicaMappings.find((rm) =>
        rm.replicaMint.equals(replicaMint)
      );
      if (replicaMapping) {
        const existingToken = tokenListTokens.find(
          (tok) => tok.address === replicaMapping.underlyingMint.toString()
        );
        if (existingToken) {
          return makeReplicaTokenInfo(
            replicaMapping.replicaMint,
            existingToken
          );
        } else {
          missingReplicaMappings.push(replicaMapping);
        }
      }
      return null;
    })
    .filter((x): x is TokenInfo => !!x);

  const missingMintsNonReplica = missingMints.filter(
    (mm) => !missingReplicaMappings.find((mrm) => mrm.replicaMint.equals(mm))
  );
  const missingMintsData = (
    await Promise.all(
      chunk(missingMintsNonReplica, 100).map(async (mintsChunk) =>
        provider.connection.getMultipleAccountsInfo(mintsChunk)
      )
    )
  ).flat();
  const missingTokens = zip(missingMintsNonReplica, missingMintsData).map(
    ([mint, mintDataRaw]) => {
      invariant(mint);
      invariant(mintDataRaw);
      return Token.fromMint(mint, deserializeMint(mintDataRaw.data).decimals, {
        chainId: networkToChainId(network),
      }).info;
    }
  );
  const missingReplicaTokens = missingReplicaMappings.map(
    ({ replicaMint, underlyingMint }) => {
      const existingToken = missingTokens.find(
        (tok) => tok.address === underlyingMint.toString()
      );
      invariant(
        existingToken,
        `missing ${underlyingMint.toString()} for ${replicaMint.toString()}`
      );
      return makeReplicaTokenInfo(replicaMint, existingToken);
    }
  );

  const tokens = dedupeTokenList([
    ...iouTokens,
    ...tokenListTokens,
    ...underlyingTokens,
    ...tokenListReplicas,
    ...missingTokens,
    ...missingReplicaTokens,
  ]);

  const list: TokenList = {
    name: `Quarry Token List (${network})`,
    logoURI:
      "https://raw.githubusercontent.com/QuarryProtocol/rewarder-list/master/icon.png",
    tags: lists.reduce((acc, list) => ({ ...acc, ...list.tags }), {}),
    timestamp: new Date().toISOString(),
    tokens,
  };

  await fs.mkdir("data/", { recursive: true });
  await fs.writeFile(`${dir}/token-list.json`, stringify(list));
}
Example #10
Source File: govern.spec.ts    From tribeca with GNU Affero General Public License v3.0 4 votes vote down vote up
describe("Govern", () => {
  const sdk = makeSDK();
  const gokiSDK = GokiSDK.load({ provider: sdk.provider });

  let governorW: GovernorWrapper;
  let smartWallet: PublicKey;

  before(async () => {
    const owners = [sdk.provider.wallet.publicKey];
    const electorate = Keypair.generate().publicKey;
    const { governorWrapper, smartWalletWrapper } = await setupGovernor({
      electorate,
      sdk,
      gokiSDK,
      owners,
    });

    smartWallet = smartWalletWrapper.key;
    governorW = governorWrapper;
  });

  it("Governor was initialized", async () => {
    const governorData = await governorW.data();
    const [governor, bump] = await findGovernorAddress(governorData.base);
    expect(governorW.governorKey).to.eqAddress(governor);

    invariant(governorData, "governor data was not loaded");

    expect(governorData.bump).to.equal(bump);
    expect(governorData.smartWallet).to.eqAddress(smartWallet);
    expect(governorData.proposalCount.toString()).to.eq(ZERO.toString());
    expect(governorData.params.votingDelay.toString()).eq(
      DEFAULT_VOTE_DELAY.toString()
    );
    expect(governorData.params.votingPeriod.toString()).eq(
      DEFAULT_VOTE_PERIOD.toString()
    );
  });

  describe("Proposal", () => {
    let proposalIndex: BN;
    let proposalKey: PublicKey;

    beforeEach("create a proposal", async () => {
      const { index, proposal, tx } = await governorW.createProposal({
        instructions: DUMMY_INSTRUCTIONS,
      });
      await expectTX(tx, "create a proposal").to.be.fulfilled;
      proposalIndex = index;
      proposalKey = proposal;
    });

    it("Proposal as initialized", async () => {
      const proposer = sdk.provider.wallet.publicKey;
      const { governorKey } = governorW;
      const [expectedProposalKey, bump] = await findProposalAddress(
        governorKey,
        proposalIndex
      );
      expect(proposalKey).to.eqAddress(expectedProposalKey);
      const governorData = await governorW.data();
      const proposalData = await governorW.fetchProposalByKey(proposalKey);
      expect(proposalData.bump).to.equal(bump);
      expect(proposalData.index).to.bignumber.equal(proposalIndex);
      expect(proposalData.canceledAt).to.bignumber.equal(ZERO);
      expect(proposalData.queuedAt).to.bignumber.equal(ZERO);
      expect(proposalData.activatedAt).to.bignumber.equal(ZERO);
      expect(proposalData.votingEndsAt).to.bignumber.equal(ZERO);
      expect(proposalData.abstainVotes).to.bignumber.equal(ZERO);
      expect(proposalData.againstVotes).to.bignumber.equal(ZERO);
      expect(proposalData.forVotes).to.bignumber.equal(ZERO);
      expect(proposalData.quorumVotes).to.bignumber.equal(
        governorData.params.quorumVotes
      );
      expect(proposalData.queuedTransaction).to.eqAddress(PublicKey.default);
      expect(proposalData.proposer).to.eqAddress(proposer);
      expect(proposalData.governor).to.eqAddress(governorKey);

      zip(proposalData.instructions, DUMMY_INSTRUCTIONS).map(
        ([actual, expected]) => {
          invariant(expected);
          expect(actual).eql(expected);
        }
      );
    });

    it("Cancel a proposal", async () => {
      const tx = governorW.cancelProposal({
        proposal: proposalKey,
      });
      await expectTX(tx, "cancel a proposal").to.be.fulfilled;

      const proposalData = await governorW.fetchProposalByKey(proposalKey);
      expect(proposalData.canceledAt).to.be.bignumber.greaterThan(ZERO);
    });

    context("Proposal meta", () => {
      it("Cannot create proposal meta if not proposer", async () => {
        const fakeProposer = Keypair.generate();
        const createMetaTX = await governorW.createProposalMeta({
          proposer: fakeProposer.publicKey,
          proposal: proposalKey,
          title: "This is my Proposal",
          descriptionLink: "https://tribeca.so",
        });
        createMetaTX.addSigners(fakeProposer);

        try {
          await createMetaTX.confirm();
        } catch (e) {
          const error = e as SendTransactionError;
          expect(error.logs?.join("/n")).to.include(
            "Program log: self.proposer != self.proposal.proposer"
          );
        }
      });

      it("Can create proposal meta", async () => {
        const expectedTitle = "This is my Proposal";
        const expectedLink = "https://tribeca.so";
        const createMetaTX = await governorW.createProposalMeta({
          proposal: proposalKey,
          title: expectedTitle,
          descriptionLink: expectedLink,
        });
        await expectTX(createMetaTX, "creating proposal meta").to.be.fulfilled;
        const metadata = await governorW.fetchProposalMeta(proposalKey);
        expect(metadata.title).to.be.equal(expectedTitle);
        expect(metadata.descriptionLink).to.be.equal(expectedLink);
        expect(metadata.proposal).to.eqAddress(proposalKey);
      });
    });
  });
});
Example #11
Source File: locked-voter.spec.ts    From tribeca with GNU Affero General Public License v3.0 4 votes vote down vote up
describe("Locked Voter", () => {
  const sdk = makeSDK();
  const gokiSDK = GokiSDK.load({ provider: sdk.provider });

  let base: PublicKey;
  let govTokenMint: PublicKey;

  let governorW: GovernorWrapper;
  let lockerW: LockerWrapper;
  let smartWalletW: SmartWalletWrapper;

  before(async () => {
    govTokenMint = await createMint(sdk.provider);

    const baseKP = Keypair.generate();
    base = baseKP.publicKey;
    const [lockerKey] = await findLockerAddress(base);

    const owners = [sdk.provider.wallet.publicKey];
    const { governorWrapper, smartWalletWrapper } = await setupGovernor({
      electorate: lockerKey,
      sdk,
      gokiSDK,
      owners,
    });

    const { locker, tx: tx1 } = await sdk.createLocker({
      baseKP,
      proposalActivationMinVotes: INITIAL_MINT_AMOUNT,
      governor: governorWrapper.governorKey,
      govTokenMint,
    });
    await expectTX(tx1, "initialize locker").to.be.fulfilled;

    lockerW = await LockerWrapper.load(
      sdk,
      locker,
      governorWrapper.governorKey
    );
    governorW = governorWrapper;
    smartWalletW = smartWalletWrapper;
  });

  let proposal: PublicKey;
  let proposalIndex: BN;
  let user: Signer;

  beforeEach("create a proposal", async () => {
    user = await createUser(sdk.provider, govTokenMint);
    const {
      proposal: proposalInner,
      index,
      tx: createProposalTx,
    } = await lockerW.governor.createProposal({
      proposer: user.publicKey,
      instructions: DUMMY_INSTRUCTIONS,
    });
    createProposalTx.addSigners(user);
    await expectTX(createProposalTx, "creating a proposal").to.be.fulfilled;
    proposal = proposalInner;
    proposalIndex = index;
  });

  it("Locked voter's electorate was initialized", async () => {
    const { locker: electorate } = lockerW;
    const electorateData = await lockerW.data();
    const [expectedLocker, bump] = await findLockerAddress(base);

    expect(electorate).eqAddress(expectedLocker);
    expect(electorateData.bump).equal(bump);
    expect(electorateData.base).eqAddress(base);
    expect(electorateData.tokenMint).eqAddress(govTokenMint);
    expect(electorateData.governor).eqAddress(governorW.governorKey);
    expect(electorateData.lockedSupply).to.bignumber.eq(ZERO);

    const { params } = electorateData;
    expect(params.maxStakeVoteMultiplier).to.eq(
      DEFAULT_LOCKER_PARAMS.maxStakeVoteMultiplier
    );
    expect(params.minStakeDuration).to.bignumber.eq(
      DEFAULT_LOCKER_PARAMS.minStakeDuration
    );
    expect(params.maxStakeDuration).to.bignumber.to.bignumber.eq(
      DEFAULT_LOCKER_PARAMS.maxStakeDuration
    );
    expect(params.proposalActivationMinVotes).to.bignumber.eq(
      INITIAL_MINT_AMOUNT
    );
  });

  it("Proposal was initialized", async () => {
    const proposer = user.publicKey;
    const electorateData = await lockerW.data();
    const [expectedProposalKey, bump] = await findProposalAddress(
      electorateData.governor,
      proposalIndex
    );
    expect(proposal).to.eqAddress(expectedProposalKey);

    const governorData = await governorW.data();
    const proposalData = await lockerW.fetchProposalData(proposal);
    expect(proposalData.bump).to.equal(bump);
    expect(proposalData.index).to.bignumber.equal(proposalIndex);
    expect(proposalData.canceledAt).to.bignumber.equal(ZERO);
    expect(proposalData.queuedAt).to.bignumber.equal(ZERO);
    expect(proposalData.activatedAt).to.bignumber.equal(ZERO);
    expect(proposalData.votingEndsAt).to.bignumber.equal(ZERO);
    expect(proposalData.abstainVotes).to.bignumber.equal(ZERO);
    expect(proposalData.againstVotes).to.bignumber.equal(ZERO);
    expect(proposalData.forVotes).to.bignumber.equal(ZERO);
    expect(proposalData.quorumVotes).to.bignumber.equal(
      governorData.params.quorumVotes
    );
    expect(proposalData.queuedTransaction).to.eqAddress(PublicKey.default);
    expect(proposalData.proposer).to.eqAddress(proposer);
    expect(proposalData.governor).to.eqAddress(governorW.governorKey);

    zip(proposalData.instructions, DUMMY_INSTRUCTIONS).map(
      ([actual, expected]) => {
        invariant(expected);
        expect(actual).eql(expected);
      }
    );
  });

  it("Cannot lock duration below min stake duration", async () => {
    user = await createUser(sdk.provider, govTokenMint);
    const tx = await lockerW.lockTokens({
      amount: INITIAL_MINT_AMOUNT,
      duration: new BN(1),
      authority: user.publicKey,
    });
    tx.addSigners(user);

    try {
      await tx.send();
    } catch (e) {
      const error = e as SendTransactionError;
      expect(
        error.logs
          ?.join("/n")
          .includes(
            "LockupDurationTooShort: Lockup duration must at least be the min stake duration."
          )
      );
    }
  });

  it("lock tokens v1 as user", async () => {
    const userV1 = await createUser(sdk.provider, govTokenMint);
    const lockTx = await lockerW.lockTokensV1({
      amount: INITIAL_MINT_AMOUNT,
      duration: DEFAULT_LOCKER_PARAMS.maxStakeDuration,
      authority: userV1.publicKey,
    });
    lockTx.addSigners(userV1);
    await expectTX(lockTx, "lock tokens").to.be.fulfilled;
  });

  describe("Escrow", () => {
    let user: Signer;
    let initialLockedSupply: BN;

    beforeEach("Create user and deposit tokens", async () => {
      initialLockedSupply = (await lockerW.reload()).lockedSupply;
      user = await createUser(sdk.provider, govTokenMint);
      const lockTx = await lockerW.lockTokens({
        amount: INITIAL_MINT_AMOUNT,
        duration: DEFAULT_LOCKER_PARAMS.maxStakeDuration,
        authority: user.publicKey,
      });
      lockTx.addSigners(user);
      await expectTX(lockTx, "lock tokens").to.be.fulfilled;
    });

    it("Escrow was initialized and locker was updated", async () => {
      const { locker } = lockerW;
      const lockerData = await lockerW.reload();

      const [escrowKey, bump] = await findEscrowAddress(locker, user.publicKey);
      const escrowATA = await getATAAddress({
        mint: lockerData.tokenMint,
        owner: escrowKey,
      });
      const escrow = await lockerW.fetchEscrow(escrowKey);
      expect(escrow.bump).equal(bump);
      expect(escrow.amount).to.bignumber.equal(INITIAL_MINT_AMOUNT);
      expect(escrow.owner).eqAddress(user.publicKey);
      expect(escrow.locker).eqAddress(locker);
      expect(escrow.tokens).eqAddress(escrowATA);
      expect(escrow.voteDelegate).eqAddress(user.publicKey);
      expect(escrow.escrowEndsAt.sub(escrow.escrowStartedAt).toString()).equal(
        DEFAULT_LOCKER_PARAMS.maxStakeDuration.toString()
      );
      await expectLockedSupply(
        lockerW,
        INITIAL_MINT_AMOUNT.add(initialLockedSupply)
      );

      const tokenAccount = await getTokenAccount(sdk.provider, escrowATA);
      expect(tokenAccount.amount, "escrow account").to.bignumber.eq(
        INITIAL_MINT_AMOUNT
      );
    });

    it("Set vote delegate", async () => {
      const expectedDelegate = Keypair.generate().publicKey;
      const tx = await lockerW.setVoteDelegate(
        expectedDelegate,
        user.publicKey
      );
      tx.addSigners(user);
      await expectTX(tx).to.be.fulfilled;

      const escrowData = await lockerW.fetchEscrowByAuthority(user.publicKey);
      expect(escrowData.voteDelegate).to.eqAddress(expectedDelegate);
    });

    it("Set vote delegate access control test", async () => {
      const expectedDelegate = Keypair.generate().publicKey;
      const incorrectAccount = Keypair.generate();
      const [escrow] = await findEscrowAddress(lockerW.locker, user.publicKey);
      const tx = new TransactionEnvelope(lockerW.sdk.provider, [
        lockerW.program.instruction.setVoteDelegate(expectedDelegate, {
          accounts: {
            escrow,
            escrowOwner: incorrectAccount.publicKey,
          },
        }),
      ]);
      tx.addSigners(incorrectAccount);
      await expectTX(tx).to.be.rejectedWith(/0x44c/);
    });

    it("Exit should fail", async () => {
      const exitTx = await lockerW.exit({ authority: user.publicKey });
      exitTx.addSigners(user);

      try {
        await exitTx.confirm();
      } catch (e) {
        const error = e as SendTransactionError;
        expect(error.logs?.join("\n")).to.include(
          "EscrowNotEnded: Escrow has not ended."
        );
      }
    });

    it("Cannot vote on inactive proposal", async () => {
      const voteTx = await lockerW.castVotes({
        voteSide: VoteSide.Abstain,
        proposal,
        authority: user.publicKey,
      });
      voteTx.addSigners(user);
      try {
        await voteTx.confirm();
      } catch (e) {
        const error = e as SendTransactionError;
        expect(error.logs?.join("\n")).to.include(
          "Invariant failed: proposal must be active"
        );
      }
    });

    it("Activate proposal", async () => {
      await sleep(3000); // sleep to pass voting delay
      const activateTx = await lockerW.activateProposal({
        proposal,
        authority: user.publicKey,
      });
      activateTx.addSigners(user);
      await expectTX(activateTx, "activate").to.be.fulfilled;
      const proposalData = await lockerW.fetchProposalData(proposal);
      expect(proposalData.activatedAt).to.bignumber.greaterThan(ZERO);
    });

    it("Escrow refresh cannot shorten the escrow time remaining", async () => {
      const lockTx = await lockerW.lockTokens({
        amount: INITIAL_MINT_AMOUNT,
        duration: ONE_YEAR.mul(new BN("4")), // 4 years < 5 years
        authority: user.publicKey,
      });
      lockTx.addSigners(user);

      try {
        await lockTx.confirm();
      } catch (e) {
        const error = e as SendTransactionError;
        expect(
          error.logs
            ?.join("\n")
            .includes("escrow refresh cannot shorten the escrow time remaining")
        );
      }
    });
  });

  it("Exit escrow", async () => {
    const { governorKey } = governorW;
    const { locker, tx } = await sdk.createLocker({
      minStakeDuration: new BN(1),
      proposalActivationMinVotes: INITIAL_MINT_AMOUNT,
      governor: governorKey,
      govTokenMint,
    });
    await expectTX(tx, "initialize locker").to.be.fulfilled;

    const shortLockerW = await LockerWrapper.load(sdk, locker, governorKey);
    const lockTx = await shortLockerW.lockTokens({
      amount: INITIAL_MINT_AMOUNT,
      duration: new BN(1),
      authority: user.publicKey,
    });
    lockTx.addSigners(user);
    await expectTX(lockTx, "short lock up").to.be.fulfilled;
    await expectLockedSupply(shortLockerW, INITIAL_MINT_AMOUNT);

    await sleep(2500); // sleep to lockup
    const exitTx = await shortLockerW.exit({ authority: user.publicKey });
    exitTx.addSigners(user);
    await expectTX(exitTx, "exit lock up").to.be.fulfilled;

    const [escrowKey] = await findEscrowAddress(locker, user.publicKey);
    try {
      await lockerW.fetchEscrow(escrowKey);
    } catch (e) {
      const error = e as Error;
      expect(error.message).to.equal(
        `Account does not exist ${escrowKey.toString()}`
      );
    }

    const userATA = await getATAAddress({
      mint: govTokenMint,
      owner: user.publicKey,
    });
    const tokenAccount = await getTokenAccount(sdk.provider, userATA);
    expect(tokenAccount.amount).to.bignumber.eq(INITIAL_MINT_AMOUNT);

    await expectLockedSupply(shortLockerW, ZERO);
  });

  describe("Voting", () => {
    let user: Signer;
    let escrowW: VoteEscrow;

    beforeEach("lock token and activate proposal", async () => {
      user = await createUser(sdk.provider, govTokenMint);
      const lockTx = await lockerW.lockTokens({
        amount: INITIAL_MINT_AMOUNT,
        duration: DEFAULT_LOCKER_PARAMS.maxStakeDuration,
        authority: user.publicKey,
      });
      lockTx.addSigners(user);
      await expectTX(lockTx, "lock tokens").to.be.fulfilled;
      await sleep(3000); // sleep to pass voting delay
      const activateTx = await lockerW.activateProposal({
        proposal,
        authority: user.publicKey,
      });
      activateTx.addSigners(user);
      await expectTX(activateTx, "activate").to.be.fulfilled;
      const { governorKey } = governorW;
      const { locker } = lockerW;

      const [escrowKey] = await findEscrowAddress(locker, user.publicKey);
      escrowW = new VoteEscrow(
        sdk,
        locker,
        governorKey,
        escrowKey,
        user.publicKey
      );
    });

    it("Cast for a proposal", async () => {
      const voteTx = await escrowW.castVote({ proposal, side: VoteSide.For });

      voteTx.addSigners(user);
      await expectTX(voteTx, "voting successful").to.be.fulfilled;

      const proposalData = await governorW.fetchProposalByKey(proposal);
      const calculator = await escrowW.makeCalculateVotingPower();
      expect(proposalData.forVotes).to.bignumber.eq(
        calculator(proposalData.votingEndsAt.toNumber())
      );
      expect(proposalData.againstVotes).to.bignumber.eq(ZERO);
      expect(proposalData.abstainVotes).to.bignumber.eq(ZERO);
    });

    it("Cast against a proposal", async () => {
      const voteTx = await escrowW.castVote({
        proposal,
        side: VoteSide.Against,
      });

      voteTx.addSigners(user);
      await expectTX(voteTx, "voting successful").to.be.fulfilled;

      const proposalData = await governorW.fetchProposalByKey(proposal);
      const calculator = await escrowW.makeCalculateVotingPower();
      expect(proposalData.forVotes).to.bignumber.eq(ZERO);
      expect(proposalData.againstVotes).to.bignumber.eq(
        calculator(proposalData.votingEndsAt.toNumber())
      );
      expect(proposalData.abstainVotes).to.bignumber.eq(ZERO);
    });

    it("Cast abstain on a proposal", async () => {
      const voteTx = await escrowW.castVote({
        proposal,
        side: VoteSide.Abstain,
      });

      voteTx.addSigners(user);
      await expectTX(voteTx, "voting successful").to.be.fulfilled;

      const proposalData = await governorW.fetchProposalByKey(proposal);
      const calculator = await escrowW.makeCalculateVotingPower();
      expect(proposalData.againstVotes).to.bignumber.eq(ZERO);
      expect(proposalData.forVotes).to.bignumber.eq(ZERO);
      expect(proposalData.abstainVotes).to.bignumber.eq(
        calculator(proposalData.votingEndsAt.toNumber())
      );
    });
  });

  describe("CPI Whitelist", () => {
    const TEST_PROGRAM_ID = new PublicKey(
      "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"
    );
    const testProgram = newProgram<WhitelistTesterProgram>(
      WhitelistTesterJSON,
      TEST_PROGRAM_ID,
      sdk.provider
    );

    beforeEach("Enable whitelist on the locker", async () => {
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.setLockerParamsIx({
            ...DEFAULT_LOCKER_PARAMS,
            whitelistEnabled: true,
          }),
        ],
      });
    });

    const buildCPITX = async (owner?: Signer): Promise<TransactionEnvelope> => {
      const { provider } = sdk;
      const user = await createUser(provider, govTokenMint, owner);
      const authority = user.publicKey;
      const { escrow, instruction: initEscrowIx } =
        await lockerW.getOrCreateEscrow(authority);

      const { address: sourceTokens, instruction: ataIx1 } =
        await getOrCreateATA({
          provider,
          mint: govTokenMint,
          owner: authority,
          payer: authority,
        });
      const { address: escrowTokens, instruction: ataIx2 } =
        await getOrCreateATA({
          provider,
          mint: govTokenMint,
          owner: escrow,
          payer: authority,
        });
      const instructions = [initEscrowIx, ataIx1, ataIx2].filter(
        (ix): ix is TransactionInstruction => !!ix
      );

      const [whitelistEntry] = await findWhitelistAddress(
        lockerW.locker,
        TEST_PROGRAM_ID,
        owner ? owner.publicKey : null
      );

      instructions.push(
        testProgram.instruction.lockTokens(INITIAL_MINT_AMOUNT, ONE_YEAR, {
          accounts: {
            locker: lockerW.locker,
            escrow,
            escrowOwner: authority,
            escrowTokens,
            sourceTokens,
            lockedVoterProgram: TRIBECA_ADDRESSES.LockedVoter,
            tokenProgram: TOKEN_PROGRAM_ID,
          },
          remainingAccounts: [
            {
              pubkey: SYSVAR_INSTRUCTIONS_PUBKEY,
              isWritable: false,
              isSigner: false,
            },
            {
              pubkey: whitelistEntry,
              isWritable: false,
              isSigner: false,
            },
          ],
        })
      );

      return new TransactionEnvelope(sdk.provider, instructions, [user]);
    };

    it("CPI fails when program is not whitelisted", async () => {
      const tx = await buildCPITX();
      try {
        await tx.confirm();
      } catch (e) {
        const error = e as Error;
        expect(error.message).to.include(
          `0x${LockedVoterErrors.ProgramNotWhitelisted.code.toString(16)}`
        );
      }
    });

    it("CPI succeeds after program has been whitelisted", async () => {
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.createApproveProgramLockPrivilegeIx(
            TEST_PROGRAM_ID,
            null
          ),
        ],
      });
      const tx = await buildCPITX();
      await expectTX(tx, "successfully locked tokens via the whitelist tester")
        .to.be.fulfilled;
    });

    it("CPI fails when program owner is not whitelisted", async () => {
      const owner = Keypair.generate();
      const faker = Keypair.generate();
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.createApproveProgramLockPrivilegeIx(
            TEST_PROGRAM_ID,
            faker.publicKey
          ),
        ],
      });
      const tx = await buildCPITX(owner);

      await assertTXThrows(tx, LockedVoterErrors.ProgramNotWhitelisted);
    });

    it("CPI succeeds after program owner has been whitelisted", async () => {
      const owner = Keypair.generate();
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.createApproveProgramLockPrivilegeIx(
            TEST_PROGRAM_ID,
            owner.publicKey
          ),
        ],
      });
      const tx = await buildCPITX(owner);
      await expectTX(tx, "successfully locked tokens via the whitelist tester")
        .to.be.fulfilled;
    });

    it("Non CPI lock invocation should succeed", async () => {
      const { provider } = sdk;
      const user = await createUser(provider, govTokenMint);
      const tx = await lockerW.lockTokens({
        amount: INITIAL_MINT_AMOUNT,
        duration: ONE_DAY,
        authority: user.publicKey,
      });
      tx.addSigners(user);
      await expectTX(tx, "lock tokens").to.be.fulfilled;
    });

    it("Remove whitelist", async () => {
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.createRevokeProgramLockPrivilegeIx(
            TEST_PROGRAM_ID,
            null
          ),
        ],
      });
    });
  });

  describe("CPI Whitelist (v2)", () => {
    const TEST_PROGRAM_ID = new PublicKey(
      "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"
    );
    const testProgram = newProgram<WhitelistTesterProgram>(
      WhitelistTesterJSON,
      TEST_PROGRAM_ID,
      sdk.provider
    );

    const buildCPITX = async (owner?: Signer): Promise<TransactionEnvelope> => {
      const { provider } = sdk;
      const user = await createUser(provider, govTokenMint, owner);
      const authority = user.publicKey;
      const { escrow, instruction: initEscrowIx } =
        await lockerW.getOrCreateEscrow(authority);

      const { address: sourceTokens, instruction: ataIx1 } =
        await getOrCreateATA({
          provider,
          mint: govTokenMint,
          owner: authority,
          payer: authority,
        });
      const { address: escrowTokens, instruction: ataIx2 } =
        await getOrCreateATA({
          provider,
          mint: govTokenMint,
          owner: escrow,
          payer: authority,
        });
      const instructions = [initEscrowIx, ataIx1, ataIx2].filter(
        (ix): ix is TransactionInstruction => !!ix
      );

      const [whitelistEntry] = await findWhitelistAddress(
        lockerW.locker,
        TEST_PROGRAM_ID,
        owner ? owner.publicKey : null
      );

      instructions.push(
        testProgram.instruction.lockTokensWithWhitelistEntry(
          INITIAL_MINT_AMOUNT,
          ONE_YEAR,
          {
            accounts: {
              locker: lockerW.locker,
              escrow,
              escrowOwner: authority,
              escrowTokens,
              sourceTokens,
              lockedVoterProgram: TRIBECA_ADDRESSES.LockedVoter,
              tokenProgram: TOKEN_PROGRAM_ID,
              instructionsSysvar: SYSVAR_INSTRUCTIONS_PUBKEY,
              whitelistEntry,
            },
          }
        )
      );

      return new TransactionEnvelope(sdk.provider, instructions, [user]);
    };

    it("CPI fails when program is not whitelisted", async () => {
      const tx = await buildCPITX();
      try {
        await tx.confirm();
      } catch (e) {
        const error = e as Error;
        expect(error.message).to.include(
          `0xbc4` // account not initialized
        );
      }
    });

    it("CPI succeeds after program has been whitelisted", async () => {
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.createApproveProgramLockPrivilegeIx(
            TEST_PROGRAM_ID,
            null
          ),
        ],
      });
      const tx = await buildCPITX();
      await expectTX(tx, "successfully locked tokens via the whitelist tester")
        .to.be.fulfilled;
    });

    it("CPI fails when program owner is not whitelisted", async () => {
      const owner = Keypair.generate();
      const faker = Keypair.generate();
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.createApproveProgramLockPrivilegeIx(
            TEST_PROGRAM_ID,
            faker.publicKey
          ),
        ],
      });
      const tx = await buildCPITX(owner);

      await expectTX(tx).to.be.rejectedWith(
        new RegExp(`custom program error: 0xbc4`)
      );
    });

    it("CPI succeeds after program owner has been whitelisted", async () => {
      const owner = Keypair.generate();
      await executeTransactionBySmartWallet({
        provider: sdk.provider,
        smartWalletWrapper: smartWalletW,
        instructions: [
          await lockerW.createApproveProgramLockPrivilegeIx(
            TEST_PROGRAM_ID,
            owner.publicKey
          ),
        ],
      });
      const tx = await buildCPITX(owner);
      await expectTX(tx, "successfully locked tokens via the whitelist tester")
        .to.be.fulfilled;
    });

    it("Non CPI lock invocation should succeed", async () => {
      const { provider } = sdk;
      const user = await createUser(provider, govTokenMint);
      const tx = await lockerW.lockTokens({
        amount: INITIAL_MINT_AMOUNT,
        duration: ONE_DAY,
        authority: user.publicKey,
      });
      tx.addSigners(user);
      await expectTX(tx, "lock tokens").to.be.fulfilled;
    });
  });
});
Example #12
Source File: EnumerableMap.behavior.ts    From balancer-v2-monorepo with GNU General Public License v3.0 4 votes vote down vote up
export function shouldBehaveLikeMap(
  store: { map: Contract },
  keys: Array<string | BigNumber>,
  values: Array<string | BigNumber>
): void {
  const [keyA, keyB, keyC] = keys;
  const [valueA, valueB, valueC] = values;

  const indexOfErrorCode = 41;
  const getErrorCode = 42;

  async function expectMembersMatch(map: Contract, keys: Array<string | BigNumber>, values: Array<string | BigNumber>) {
    expect(keys.length).to.equal(values.length);

    await Promise.all(keys.map(async (key) => expect(await map.contains(key)).to.equal(true)));

    expect(await map.length()).to.equal(keys.length.toString());

    expect(await Promise.all(keys.map((key) => map.get(key, getErrorCode)))).to.deep.equal(values);

    // To compare key-value pairs, we zip keys and values, and convert BNs to
    // strings to workaround Chai limitations when dealing with nested arrays
    expect(
      await Promise.all(
        [...Array(keys.length).keys()].map(async (index) => {
          const entryAt = await map.at(index);
          const entryAtUnchecked = await map.unchecked_at(index);
          const valueAtUnchecked = await map.unchecked_valueAt(index);

          expect(entryAt.key).to.equal(entryAtUnchecked.key);
          expect(entryAt.value).to.equal(entryAtUnchecked.value);
          expect(entryAt.value).to.equal(valueAtUnchecked);

          return [entryAt.key.toString(), entryAt.value];
        })
      )
    ).to.have.same.deep.members(
      zip(
        keys.map((k) => k.toString()),
        values
      )
    );
  }

  it('starts empty', async () => {
    expect(await store.map.contains(keyA)).to.equal(false);

    await expectMembersMatch(store.map, [], []);
  });

  describe('set', () => {
    it('adds a key', async () => {
      const receipt = await (await store.map.set(keyA, valueA)).wait();
      expectEvent.inReceipt(receipt, 'OperationResult', { result: true });

      await expectMembersMatch(store.map, [keyA], [valueA]);
    });

    it('adds several keys', async () => {
      await store.map.set(keyA, valueA);
      await store.map.set(keyB, valueB);

      await expectMembersMatch(store.map, [keyA, keyB], [valueA, valueB]);
      expect(await store.map.contains(keyC)).to.equal(false);
    });

    it('returns false when adding keys already in the set', async () => {
      await store.map.set(keyA, valueA);

      const receipt = await (await store.map.set(keyA, valueA)).wait();
      expectEvent.inReceipt(receipt, 'OperationResult', { result: false });

      await expectMembersMatch(store.map, [keyA], [valueA]);
    });

    it('updates values for keys already in the set', async () => {
      await store.map.set(keyA, valueA);

      await store.map.set(keyA, valueB);

      await expectMembersMatch(store.map, [keyA], [valueB]);
    });
  });

  describe('get', () => {
    it('returns the value for a key', async () => {
      await store.map.set(keyA, valueA);

      expect(await store.map.get(keyA, getErrorCode)).to.equal(valueA);
    });

    it('reverts with a custom message if the key is not in the map', async () => {
      await expect(store.map.get(keyA, getErrorCode)).to.be.revertedWith(getErrorCode.toString());
    });
  });

  describe('indexOf', () => {
    it('returns the index of an added key', async () => {
      await store.map.set(keyA, valueA);
      await store.map.set(keyB, valueB);

      expect(await store.map.indexOf(keyA, indexOfErrorCode)).to.equal(0);
      expect(await store.map.indexOf(keyB, indexOfErrorCode)).to.equal(1);
    });

    it('adding and removing keys can change the index', async () => {
      await store.map.set(keyA, valueA);
      await store.map.set(keyB, valueB);

      await store.map.remove(keyA);

      // B is now the only element; its index must be 0
      expect(await store.map.indexOf(keyB, indexOfErrorCode)).to.equal(0);
    });

    it('reverts if the key is not in the map', async () => {
      await expect(store.map.indexOf(keyA, indexOfErrorCode)).to.be.revertedWith(indexOfErrorCode.toString());
    });
  });

  describe('unchecked_indexOf', () => {
    it('returns the index of an added key, plus one', async () => {
      await store.map.set(keyA, valueA);
      await store.map.set(keyB, valueB);

      expect(await store.map.unchecked_indexOf(keyA)).to.equal(0 + 1);
      expect(await store.map.unchecked_indexOf(keyB)).to.equal(1 + 1);
    });

    it('adding and removing keys can change the index', async () => {
      await store.map.set(keyA, valueA);
      await store.map.set(keyB, valueB);

      await store.map.remove(keyA);

      // B is now the only element; its index must be 0
      expect(await store.map.unchecked_indexOf(keyB)).to.equal(0 + 1);
    });

    it('returns a zero index if the key is not in the map', async () => {
      expect(await store.map.unchecked_indexOf(keyA)).to.be.equal(0);
    });
  });

  describe('unchecked_setAt', () => {
    it('updates a value', async () => {
      await store.map.set(keyA, valueA);

      const indexA = (await store.map.unchecked_indexOf(keyA)) - 1;
      await store.map.unchecked_setAt(indexA, valueB);

      await expectMembersMatch(store.map, [keyA], [valueB]);
    });

    it('updates several values', async () => {
      await store.map.set(keyA, valueA);
      await store.map.set(keyB, valueB);

      const indexA = (await store.map.unchecked_indexOf(keyA)) - 1;
      const indexB = (await store.map.unchecked_indexOf(keyB)) - 1;

      await store.map.unchecked_setAt(indexA, valueC);
      await store.map.unchecked_setAt(indexB, valueA);

      await expectMembersMatch(store.map, [keyA, keyB], [valueC, valueA]);
    });

    it('does not revert when setting indexes outside of the map', async () => {
      const length = await store.map.length();
      await store.map.unchecked_setAt(length, valueC);
    });
  });

  describe('unchecked_at', () => {
    it('returns an entry at an index', async () => {
      await store.map.set(keyA, valueA);

      const indexA = (await store.map.unchecked_indexOf(keyA)) - 1;
      const entry = await store.map.unchecked_at(indexA);

      expect(entry.key).to.equal(keyA);
      expect(entry.value).to.equal(valueA);
    });

    it('does not revert when accessing indexes outside of the map', async () => {
      const length = await store.map.length();
      await store.map.unchecked_at(length);
    });
  });

  describe('unchecked_valueAt', () => {
    it('returns a value at an index', async () => {
      await store.map.set(keyA, valueA);

      const indexA = (await store.map.unchecked_indexOf(keyA)) - 1;
      const value = await store.map.unchecked_valueAt(indexA);

      expect(value).to.equal(valueA);
    });

    it('does not revert when accessing indexes outside of the map', async () => {
      const length = await store.map.length();
      await store.map.unchecked_valueAt(length);
    });
  });

  describe('remove', () => {
    it('removes added keys', async () => {
      await store.map.set(keyA, valueA);

      const receipt = await (await store.map.remove(keyA)).wait();
      expectEvent.inReceipt(receipt, 'OperationResult', { result: true });

      expect(await store.map.contains(keyA)).to.equal(false);
      await expectMembersMatch(store.map, [], []);
    });

    it('returns false when removing keys not in the set', async () => {
      const receipt = await (await store.map.remove(keyA)).wait();
      expectEvent.inReceipt(receipt, 'OperationResult', { result: false });

      expect(await store.map.contains(keyA)).to.equal(false);
    });

    it('adds and removes multiple keys', async () => {
      // []

      await store.map.set(keyA, valueA);
      await store.map.set(keyC, valueC);

      // [A, C]

      await store.map.remove(keyA);
      await store.map.remove(keyB);

      // [C]

      await store.map.set(keyB, valueB);

      // [C, B]

      await store.map.set(keyA, valueA);
      await store.map.remove(keyC);

      // [A, B]

      await store.map.set(keyA, valueA);
      await store.map.set(keyB, valueB);

      // [A, B]

      await store.map.set(keyC, valueC);
      await store.map.remove(keyA);

      // [B, C]

      await store.map.set(keyA, valueA);
      await store.map.remove(keyB);

      // [A, C]

      await expectMembersMatch(store.map, [keyA, keyC], [valueA, valueC]);

      expect(await store.map.contains(keyB)).to.equal(false);
    });
  });
}
Example #13
Source File: attach-to-one-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('attachToOneSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag}`;
			const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer w/ count module',
		integrationTest(async () => {
			// Create a macro to expand to "value" to test .query vs .effectiveQuery
			const macroName = uuidv4().toUpperCase();
			const createOneMacro = makeCreateOneMacro(TEST_BASE_API_CONTEXT);
			const deleteOneMacro = makeDeleteOneMacro(TEST_BASE_API_CONTEXT);
			const createdMacro = await createOneMacro({ name: macroName, expansion: 'value' });

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json $${macroName} | count`;
			//const effectiveQuery = `tag=${tag} json value | count`;
			const metadata = { test: 'abc' };

			const searchCreated = await subscribeToOneSearch(query, { metadata, filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as TextSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const progressP = lastValueFrom(
				search.progress$.pipe(
					takeWhile(v => v < 100, true),
					toArray(),
				),
			);

			const statsP = lastValueFrom(search.stats$.pipe(takeWhile(s => !s.finished, true)));

			const [textEntries, progress, stats] = await Promise.all([textEntriesP, progressP, statsP]);

			////
			// Check stats
			////
			expect(stats.pipeline.length)
				.withContext('there should be two modules for this query: json and count')
				.toEqual(2);
			const [jsonModule, countModule] = stats.pipeline;

			expect(jsonModule.module).toEqual('json');
			expect(jsonModule.input.entries).withContext('json module should accept 100 entries of input').toEqual(count);
			expect(jsonModule.output.entries).withContext('json module should produce 100 entries of output').toEqual(count);

			expect(countModule.module).toEqual('count');
			expect(countModule.input.entries).withContext('count module should accept 100 entries of input').toEqual(count);
			expect(countModule.output.entries)
				.withContext('count module should produce 1 entry of output -- the count')
				.toEqual(1);

			expect(stats.metadata)
				.withContext('the search metadata should be present in the stats and unchanged')
				.toEqual(metadata);
			expect(stats.query).withContext(`Stats should contain the user query`).toBe(query);
			// TODO: Waiting on gravwell/gravwell#3677
			// expect(stats.effectiveQuery).withContext(`Stats should contain the effective query`).toBe(effectiveQuery);

			expect(stats.downloadFormats.sort())
				.withContext(`Download formats should include .json', .text', .csv' and .archive`)
				.toEqual(['archive', 'csv', 'json', 'text']);

			////
			// Check progress
			////
			if (progress.length > 1) {
				expect(progress[0].valueOf())
					.withContext('If more than one progress was emitted, the first should be 0')
					.toEqual(0);
			}
			expect(lastElt(progress)?.valueOf()).withContext('The last progress emitted should be 100%').toEqual(100);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('There should be only one entry, since we used the count module')
				.toEqual(1);
			const lastEntry = textEntries.data[0];
			expect(lastEntry).toBeDefined();
			expect(base64.decode(lastEntry.data))
				.withContext('The total count of entries should equal what we ingested')
				.toEqual(`count ${count}`);

			await deleteOneMacro(createdMacro.id);
		}),
		25000,
	);

	it(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchCreated = await subscribeToOneSearch(query, { filter });
			const search = await attachToOneSearch(searchCreated.searchID, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
				const _value = enumeratedValues.find(v => v.name === 'value')!;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			expect(stats[0].tags).withContext('Tag should match tag from query').toEqual([tag]);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it(
		'Should treat multiple searches with the same query independently',
		integrationTest(async () => {
			// Number of multiple searches to create at the same time
			const SEARCHES_N = 4;

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchesCreated = await Promise.all(
				Array.from({ length: SEARCHES_N }).map(() => subscribeToOneSearch(query, { filter })),
			);
			const searches = await Promise.all(
				searchesCreated.map(searchCreated => attachToOneSearch(searchCreated.searchID, { filter })),
			);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			const testsP = searches.map(async (search, i) => {
				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length)
					.withContext('The number of entries should equal the total ingested')
					.toEqual(count);

				if (isUndefined(textEntries.filter) === false) {
					expect(textEntries.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
					if (isUndefined(entry) || isUndefined(original)) {
						fail('Exptected all entries and original data to be defined');
						return;
					}

					const value: Entry = JSON.parse(base64.decode(entry.data));
					const enumeratedValues = entry.values;
					const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
					const _value = enumeratedValues.find(v => v.name === 'value')!;

					expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
						isEnumerated: true,
						name: 'timestamp',
						value: original.timestamp,
					});

					expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
						isEnumerated: true,
						name: 'value',
						value: original.value.toString(),
					});

					expect(value.value)
						.withContext('Each value should match its index, descending')
						.toEqual(count - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).toBeGreaterThan(0);

				if (isUndefined(stats[0].filter) === false) {
					expect(stats[0].filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
			});

			await Promise.all(testsP);
		}),
		25000,
	);

	it(
		'Should reject on an inexistent search ID',
		integrationTest(async () => {
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);
			const searchID = `4723947892379482378`;
			await expectAsync(attachToOneSearch(searchID)).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject searches with invalid search IDs without affecting good ones',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const goodSearchID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;
			const badSearchID = `4723947892379482378`;

			// Attach to a bunch of search subscriptions with different search IDs to race them
			await Promise.all([
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(goodSearchID)).withContext('valid search ID should resolve').toBeResolved(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should work with several searches initiated simultaneously',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const searchCreatedID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;

			// Attach to a bunch of search subscriptions to race them
			await Promise.all(
				rangeLeft(0, 20).map(x =>
					expectAsync(attachToOneSearch(searchCreatedID)).withContext('good query should resolve').toBeResolved(),
				),
			);
		}),
		25000,
	);

	describe('stats', () => {
		it(
			'Should be evenly spread over a window matching the zoom/overview granularity',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const minutes = 90;
				const dateRange = { start, end: addMinutes(start, minutes) };

				const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange } });
				const search = await attachToOneSearch(searchCreated.searchID);

				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e?.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length).withContext("Should be 90 entries since it's a 90 minute window").toEqual(90);
				textEntries.data.forEach((entry, index) => {
					const value: Entry = JSON.parse(base64.decode(entry.data));
					expect(value.value).toEqual(minutes - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).withContext('expect to receive >0 stats from the stats observable').toBeGreaterThan(0);
				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsOverview should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsOverview.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsOverview element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsZoom should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsZoom.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsZoom element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 640;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than the total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should provide the minimum zoom window',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const dateRange = { start, end };

				// Issue a query where the minzoomwindow is predictable (1 second)
				const query1s = `tag=${tag} json value | stats mean(value) over 1s`;
				const filter1s: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange };

				const search1sCreated = await subscribeToOneSearch(query1s, { filter: filter1s });
				const search1s = await attachToOneSearch(search1sCreated.searchID, { filter: filter1s });

				const stats1s = await lastValueFrom(search1s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats1s.minZoomWindow).toEqual(1);
				if (isUndefined(stats1s.filter) === false) {
					expect(stats1s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1s);
				}

				// Issue a query where the minzoomwindow is predictable (33 seconds, why not)
				const query33s = `tag=${tag} json value | stats mean(value) over 33s`;
				const filter33s = { entriesOffset: { index: 0, count: count }, dateRange };

				const search33sCreated = await subscribeToOneSearch(query33s, { filter: filter33s });
				const search33s = await attachToOneSearch(search33sCreated.searchID, { filter: filter33s });

				const stats33s = await lastValueFrom(search33s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats33s.minZoomWindow).toEqual(33);
				if (isUndefined(stats33s.filter) === false) {
					expect(stats33s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter33s);
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should keep the dateRange when update the filter multiple times',
			integrationTest(
				makeKeepDataRangeTest({
					start,
					end,
					count,
					createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
						const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
						const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

						const query = `tag=*`;
						const searchCreated = await subscribeToOneSearch(query, { filter: initialFilter });
						return await attachToOneSearch(searchCreated.searchID, { filter: initialFilter });
					},
				}),
			),
			25000,
		);
	});
});
Example #14
Source File: subscribe-to-one-explorer-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('subscribeToOneExplorerSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: { foo: i } };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}

		// Create an AX definition for the generated tag
		const createOneAutoExtractor = makeCreateOneAutoExtractor(TEST_BASE_API_CONTEXT);
		await createOneAutoExtractor({
			tag: tag,
			name: `${tag} - JSON`,
			description: '-',
			module: 'json',
			parameters: 'timestamp value value.foo',
		});
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const search = await subscribeToOneExplorerSearch(query, { filter: { dateRange: { start, end } } });

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	it(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} ax | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneExplorerSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries & { explorerEntries: Array<DataExplorerEntry> }),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			const explorerEntries = textEntries.explorerEntries;
			expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
				.withContext('Expect a promise of an array of data explorer entries')
				.toBeTrue();
			expect(explorerEntries.length).withContext(`Expect ${count} entries`).toBe(count);

			for (const entry of explorerEntries) {
				expect(entry.tag).withContext(`Expect entry tag to be "${tag}"`).toBe(tag);

				expect(entry.elements.length)
					.withContext(`Expect to have 2 data explorer elements on first depth level`)
					.toBe(2);
				expect(entry.elements.map(el => el.name).sort())
					.withContext(`Expect first depth data explorer elements to be "value" and "timestamp"`)
					.toEqual(['timestamp', 'value']);
				expect(entry.elements.map(el => el.module))
					.withContext(`Expect explorer module to be JSON`)
					.toEqual(['json', 'json']);

				const timestampEl = entry.elements.find(el => el.name === 'timestamp')!;
				const valueEl = entry.elements.find(el => el.name === 'value')!;

				expect(timestampEl.children.length).withContext(`Expect the timestamp element to not have children`).toBe(0);
				expect(valueEl.children.length).withContext(`Expect the value element to have one children`).toBe(1);
				expect(valueEl.children[0].name)
					.withContext(`Expect the value element child to be value.foo`)
					.toBe('value.foo');
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp');
				const _value = enumeratedValues.find(v => v.name === 'value');

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value)
					.withContext(`Each entry should have an enumerated value called "value"`)
					.toEqual({
						isEnumerated: true,
						name: 'value',
						value: JSON.stringify(original.value),
					});

				expect(value.value.foo)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it('Should be able to apply element filters', async () => {
		const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);

		const unfilteredQuery = `tag=${tag} raw`;
		const elementFilters: Array<ElementFilter> = [
			{ path: 'value.foo', operation: '!=', value: '50', tag, module: 'json', arguments: null },
		];
		const query = `tag=${tag} json "value.foo" != "50" as "foo" | raw`;
		const countAfterFilter = count - 1;

		const filter: SearchFilter = {
			entriesOffset: { index: 0, count: count },
			elementFilters,
			dateRange: { start, end },
		};
		const search = await subscribeToOneExplorerSearch(unfilteredQuery, { filter });

		const textEntriesP = lastValueFrom(
			search.entries$.pipe(
				map(e => e as RawSearchEntries & { explorerEntries: Array<DataExplorerEntry> }),
				takeWhile(e => !e.finished, true),
			),
		);

		const statsP = lastValueFrom(
			search.stats$.pipe(
				takeWhile(e => !e.finished, true),
				toArray(),
			),
		);

		const [textEntries, stats] = await Promise.all([textEntriesP, statsP]);

		////
		// Check entries
		////
		expect(textEntries.data.length)
			.withContext('The number of entries should equal the total ingested')
			.toEqual(countAfterFilter);

		if (isUndefined(textEntries.filter) === false) {
			expect(textEntries.filter)
				.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
				.toPartiallyEqual(filter);
		}

		const explorerEntries = textEntries.explorerEntries;
		expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
			.withContext('Expect a promise of an array of data explorer entries')
			.toBeTrue();
		expect(explorerEntries.length).withContext(`Expect ${countAfterFilter} entries`).toBe(countAfterFilter);

		////
		// Check stats
		////
		expect(stats.length).toBeGreaterThan(0);
		expect(stats[0].query).toBe(query);
	});

	it(
		'Should reject on a bad query string',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `this is an invalid query`;
			const range: [Date, Date] = [start, end];
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			await expectAsync(subscribeToOneExplorerSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject on a bad query range (end is before start)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: { start, end: subMinutes(start, 10) },
			};

			await expectAsync(subscribeToOneExplorerSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (different queries)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions with different queries to race them
			await Promise.all([
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "a"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "b"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "c"`, { filter: { ...baseFilter, dateRange: goodRange } }),
				)
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "d"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "e"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (same query)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions to race them
			await Promise.all([
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should send error over error$ when Last is less than First',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} chart`;

			// Use an invalid filter, where Last is less than First
			const filter: SearchFilter = { entriesOffset: { index: 1, count: -1 }, dateRange: { start, end } };

			const search = await subscribeToOneExplorerSearch(query, { filter });

			// Non-error observables should error
			await Promise.all([
				expectAsync(lastValueFrom(search.progress$)).withContext('progress$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.entries$)).withContext('entries$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.stats$)).withContext('stats$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsOverview$)).withContext('statsOverview$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsZoom$)).withContext('statsZoom$ should error').toBeRejected(),
			]);

			// errors$ should emit one item (the error) and resolve
			const error = await lastValueFrom(search.errors$);

			expect(error).toBeDefined();
			expect(error.name.length).toBeGreaterThan(0);
			expect(error.message.length).toBeGreaterThan(0);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer and preview flag',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: 'preview',
			};
			const search = await subscribeToOneExplorerSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should be less than the total ingested for preview mode')
				.toBeLessThan(count);
			expect(textEntries.data.length).withContext('The number of entries should be more than zero').toBeGreaterThan(0);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			// Zip the results with the orignal, slicing the original to the length of the results, since
			// the preview flag limits the number of results we get back
			const trimmedOriginal = reversedData.slice(0, textEntries.data.length);
			expect(trimmedOriginal.length)
				.withContext('Lengths should match (sanity check)')
				.toEqual(textEntries.data.length);

			expect(
				zip(trimmedOriginal.slice(0, trimmedOriginal.length - 1), trimmedOriginal.slice(1)).reduce(
					(isDesc, [prev, cur]) => {
						if (prev === undefined || cur === undefined) {
							throw new Error('Zipped values were not the same length.');
						}
						return prev.value.foo > cur.value.foo && isDesc;
					},
					true,
				),
			)
				.withContext('original (trimmed and reversed) data should have values in descending order')
				.toBeTrue();

			expect(
				zip(textEntries.data.slice(0, textEntries.data.length - 1), textEntries.data.slice(1)).reduce(
					(isDesc, [prevEntry, curEntry]) => {
						if (prevEntry === undefined || curEntry === undefined) {
							throw new Error('Zipped values were not the same length.');
						}
						const prevValue: Entry = JSON.parse(base64.decode(prevEntry.data));
						const curValue: Entry = JSON.parse(base64.decode(curEntry.data));

						return prevValue.value.foo > curValue.value.foo && isDesc;
					},
					true,
				),
			)
				.withContext('received entry data should have values in descending order')
				.toBeTrue();

			zip(textEntries.data, trimmedOriginal).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail("All data should be defined, since we've sliced the original data to match the preview results");
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const [_timestamp, _value] = enumeratedValues;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value)
					.withContext(`Each entry should have an enumerated value called "value"`)
					.toEqual({
						isEnumerated: true,
						name: 'value',
						value: JSON.stringify(original.value),
					});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual({ foo: count - index - 1 });
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext(
					'The sum of counts from statsOverview should be less than the total count ingested in preview mode',
				)
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsOverview.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsOverview should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should be less than the total count ingested in preview mode')
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsZoom.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsZoom should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);

			// See if we can change the date range
			const lastEntriesP = lastValueFrom(
				search.entries$.pipe(
					takeWhile(e => datesAreEqual(e.start, start) === false, true),
					last(),
				),
			);
			search.setFilter({ dateRange: { start, end } });
			const lastEntries = await lastEntriesP;

			expect(datesAreEqual(lastEntries.start, start))
				.withContext(`Start date should be the one we just set`)
				.toBeTrue();
			expect(datesAreEqual(lastEntries.end, end)).withContext(`End date should be the one we just set`).toBeTrue();
		}),
		25000,
	);

	it(
		'Should keep the dateRange when update the filter multiple times',
		integrationTest(
			makeKeepDataRangeTest({
				start,
				end,
				count,
				createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
					const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);

					const query = `tag=*`;

					return await subscribeToOneExplorerSearch(query, { filter: initialFilter });
				},
			}),
		),
		25000,
	);
});
Example #15
Source File: subscribe-to-one-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('subscribeToOneSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const search = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer w/ count module',
		integrationTest(async () => {
			// Create a macro to expand to "value" to test .query vs .effectiveQuery
			const macroName = uuidv4().toUpperCase();
			const createOneMacro = makeCreateOneMacro(TEST_BASE_API_CONTEXT);
			const deleteOneMacro = makeDeleteOneMacro(TEST_BASE_API_CONTEXT);
			const createdMacro = await createOneMacro({ name: macroName, expansion: 'value' });

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json $${macroName} | count`;
			const effectiveQuery = `tag=${tag} json value | count`;
			const metadata = { test: 'abc' };
			const search = await subscribeToOneSearch(query, { metadata, filter: { dateRange: { start, end } } });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as TextSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const progressP = lastValueFrom(
				search.progress$.pipe(
					takeWhile(v => v < 100, true),
					toArray(),
				),
			);

			const statsP = lastValueFrom(search.stats$.pipe(takeWhile(s => !s.finished, true)));

			const [textEntries, progress, stats] = await Promise.all([textEntriesP, progressP, statsP]);

			////
			// Check stats
			////
			expect(stats.pipeline.length)
				.withContext('there should be two modules for this query: json and count')
				.toEqual(2);
			const [jsonModule, countModule] = stats.pipeline;

			expect(jsonModule.module).toEqual('json');
			expect(jsonModule.input.entries).withContext('json module should accept 100 entries of input').toEqual(count);
			expect(jsonModule.output.entries).withContext('json module should produce 100 entries of output').toEqual(count);

			expect(countModule.module).toEqual('count');
			expect(countModule.input.entries).withContext('count module should accept 100 entries of input').toEqual(count);
			expect(countModule.output.entries)
				.withContext('count module should produce 1 entry of output -- the count')
				.toEqual(1);

			expect(stats.metadata)
				.withContext('the search metadata should be present in the stats and unchanged')
				.toEqual(metadata);

			expect(stats.query).withContext(`Stats should contain the user query`).toBe(query);
			expect(stats.effectiveQuery).withContext(`Stats should contain the effective query`).toBe(effectiveQuery);

			expect(stats.downloadFormats.sort())
				.withContext(`Download formats should include .json', .text', .csv' and .archive`)
				.toEqual(['archive', 'csv', 'json', 'text']);

			////
			// Check progress
			////
			if (progress.length > 1) {
				expect(progress[0].valueOf())
					.withContext('If more than one progress was emitted, the first should be 0')
					.toEqual(0);
			}
			expect(lastElt(progress)?.valueOf()).withContext('The last progress emitted should be 100%').toEqual(100);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('There should be only one entry, since we used the count module')
				.toEqual(1);
			const lastEntry = textEntries.data[0];
			expect(lastEntry).toBeDefined();
			expect(base64.decode(lastEntry.data))
				.withContext('The total count of entries should equal what we ingested')
				.toEqual(`count ${count}`);

			await deleteOneMacro(createdMacro.id);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
				const _value = enumeratedValues.find(v => v.name === 'value')!;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			expect(stats[0].tags).withContext('Tag should match tag from query').toEqual([tag]);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it(
		'Should treat multiple searches with the same query independently',
		integrationTest(async () => {
			// Number of multiple searches to create at the same time
			const SEARCHES_N = 4;

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searches = await Promise.all(
				Array.from({ length: SEARCHES_N }).map(() => subscribeToOneSearch(query, { filter })),
			);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			const testsP = searches.map(async (search, i) => {
				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length)
					.withContext('The number of entries should equal the total ingested')
					.toEqual(count);

				if (isUndefined(textEntries.filter) === false) {
					expect(textEntries.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
					if (isUndefined(entry) || isUndefined(original)) {
						fail('Exptected all entries and original data to be defined');
						return;
					}

					const value: Entry = JSON.parse(base64.decode(entry.data));
					const enumeratedValues = entry.values;
					const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
					const _value = enumeratedValues.find(v => v.name === 'value')!;

					expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
						isEnumerated: true,
						name: 'timestamp',
						value: original.timestamp,
					});

					expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
						isEnumerated: true,
						name: 'value',
						value: original.value.toString(),
					});

					expect(value.value)
						.withContext('Each value should match its index, descending')
						.toEqual(count - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).toBeGreaterThan(0);

				if (isUndefined(stats[0].filter) === false) {
					expect(stats[0].filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
			});

			await Promise.all(testsP);
		}),
		25000,
	);

	it(
		'Should reject on a bad query string',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `this is an invalid query`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			await expectAsync(subscribeToOneSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject on a bad query range (end is before start)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: { start, end: subMinutes(start, 10) },
			};

			await expectAsync(subscribeToOneSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (different queries)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions with different queries to race them
			await Promise.all([
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "a"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "b"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "c"`, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "d"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "e"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (same query)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions to race them
			await Promise.all([
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should work with several searches initiated simultaneously',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			// Start a bunch of search subscriptions to race them
			await Promise.all(
				rangeLeft(0, 20).map(x =>
					expectAsync(subscribeToOneSearch(`tag=${tag} regex ${x}`, { filter }))
						.withContext('good query should resolve')
						.toBeResolved(),
				),
			);
		}),
		25000,
	);

	it(
		'Should send error over error$ when Last is less than First',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} chart`;

			// Use an invalid filter, where Last is less than First
			const filter: SearchFilter = { entriesOffset: { index: 1, count: -1 }, dateRange: { start, end } };

			const search = await subscribeToOneSearch(query, { filter });

			// Non-error observables should error
			await Promise.all([
				expectAsync(lastValueFrom(search.progress$)).withContext('progress$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.entries$)).withContext('entries$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.stats$)).withContext('stats$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsOverview$)).withContext('statsOverview$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsZoom$)).withContext('statsZoom$ should error').toBeRejected(),
			]);

			// errors$ should emit one item (the error) and resolve
			const error = await lastValueFrom(search.errors$);

			expect(error).toBeDefined();
			expect(error.name.length).toBeGreaterThan(0);
			expect(error.message.length).toBeGreaterThan(0);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer and preview flag',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: 'preview',
			};
			const search = await subscribeToOneSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should be less than the total ingested for preview mode')
				.toBeLessThan(count);
			expect(textEntries.data.length).withContext('The number of entries should be more than zero').toBeGreaterThan(0);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			// Zip the results with the orignal, slicing the original to the length of the results, since
			// the preview flag limits the number of results we get back
			const trimmedOriginal = reversedData.slice(0, textEntries.data.length);
			expect(trimmedOriginal.length)
				.withContext('Lengths should match (sanity check)')
				.toEqual(textEntries.data.length);

			zip(textEntries.data, trimmedOriginal).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail("All data should be defined, since we've sliced the original data to match the preview results");
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const [_timestamp, _value] = enumeratedValues;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext(
					'The sum of counts from statsOverview should be less than the total count ingested in preview mode',
				)
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsOverview.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsOverview should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should be less than the total count ingested in preview mode')
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsZoom.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsZoom should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);

			// See if we can change the date range
			const lastEntriesP = lastValueFrom(
				search.entries$.pipe(takeWhile(e => datesAreEqual(e.start, start) === false, true)),
			);
			search.setFilter({ dateRange: { start, end } });
			const lastEntries = await lastEntriesP;

			expect(datesAreEqual(lastEntries.start, start))
				.withContext(`Start date should be the one we just set`)
				.toBeTrue();
			expect(datesAreEqual(lastEntries.end, end)).withContext(`End date should be the one we just set`).toBeTrue();
		}),
		25000,
	);

	describe('stats', () => {
		it(
			'Should be evenly spread over a window matching the zoom/overview granularity',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const minutes = 90;
				const dateRange = { start, end: addMinutes(start, minutes) };
				const search = await subscribeToOneSearch(query, { filter: { dateRange } });

				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length).withContext("Should be 90 entries since it's a 90 minute window").toEqual(90);
				textEntries.data.forEach((entry, index) => {
					const value: Entry = JSON.parse(base64.decode(entry.data));
					expect(value.value).toEqual(minutes - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).withContext('expect to receive >0 stats from the stats observable').toBeGreaterThan(0);
				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsOverview should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsOverview.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsOverview element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsZoom should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsZoom.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsZoom element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 640;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than the total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should provide the minimum zoom window',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);

				const dateRange = { start, end };

				// Issue a query where the minzoomwindow is predictable (1 second)
				const query1s = `tag=${tag} json value | stats mean(value) over 1s`;
				const filter1s: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange };
				const search1s = await subscribeToOneSearch(query1s, { filter: filter1s });

				const stats1s = await lastValueFrom(search1s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats1s.minZoomWindow).toEqual(1);
				if (isUndefined(stats1s.filter) === false) {
					expect(stats1s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1s);
				}

				// Issue a query where the minzoomwindow is predictable (33 seconds, why not)
				const query33s = `tag=${tag} json value | stats mean(value) over 33s`;
				const filter33s = { entriesOffset: { index: 0, count: count }, dateRange };
				const search33s = await subscribeToOneSearch(query33s, { filter: filter33s });

				const stats33s = await lastValueFrom(search33s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats33s.minZoomWindow).toEqual(33);
				if (isUndefined(stats33s.filter) === false) {
					expect(stats33s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter33s);
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter1);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should keep the dateRange when update the filter multiple times',
			integrationTest(
				makeKeepDataRangeTest({
					start,
					end,
					count,
					createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
						const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);

						const query = `tag=*`;

						return await subscribeToOneSearch(query, { filter: initialFilter });
					},
				}),
			),
			25000,
		);
	});
});
Example #16
Source File: queue.service.spec.ts    From office-hours with GNU General Public License v3.0 4 votes vote down vote up
describe('QueueService', () => {
  let service: QueueService;

  let conn: Connection;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [TestTypeOrmModule, TestConfigModule],
      providers: [QueueService, AlertsService],
    }).compile();

    service = module.get<QueueService>(QueueService);
    conn = module.get<Connection>(Connection);
  });

  afterAll(async () => {
    await conn.close();
  });

  beforeEach(async () => {
    await conn.synchronize(true);
  });

  // create 1 question for each status that exists, and put them all in a queue
  async function createQuestionsEveryStatus(
    queue: QueueModel,
  ): Promise<QuestionModel[]> {
    const allStatuses = Object.values(QuestionStatusKeys);
    const questions = await QuestionFactory.createList(allStatuses.length, {
      queue,
    });
    for (const [status, question] of zip(allStatuses, questions)) {
      question.status = status;
    }
    await QuestionModel.save(questions);
    return questions;
  }

  describe('getQuestions', () => {
    it('only returns questions in the given queue', async () => {
      const queue = await QueueFactory.create();
      await QuestionFactory.create();
      await QuestionFactory.create({ queue });
      expect((await service.getQuestions(queue.id)).queue.length).toEqual(1);
    });

    it('filters questions by status appropriately', async () => {
      const queue = await QueueFactory.create();
      await createQuestionsEveryStatus(queue);

      const questionResponse = await service.getQuestions(queue.id);
      const statuses = mapValues(
        questionResponse,
        (questions: QuestionModel[]) => questions.map((q) => q.status),
      );
      expect(statuses).toEqual({
        priorityQueue: ['PriorityQueued'],
        questionsGettingHelp: ['Helping'],
        queue: ['Queued', 'Drafting'],
        groups: [],
        unresolvedAlerts: [],
      });
    });

    it('sorts queue questions by createdat', async () => {
      const queue = await QueueFactory.create();
      const questionIds = [];
      for (let i = 0; i < 3; i++) {
        const question = await QuestionFactory.create({
          queue,
          createdAt: new Date(Date.now() + i * 1000),
        });
        questionIds.push(question.id);
      }

      expect(
        (await service.getQuestions(queue.id)).queue.map((q) => q.id),
      ).toEqual(questionIds);
    });

    it('sorts priority queue questions by createdat', async () => {
      const queue = await QueueFactory.create();
      const questionIds = [];
      for (let i = 0; i < 3; i++) {
        const question = await QuestionFactory.create({
          queue,
          status: 'PriorityQueued',
          createdAt: new Date(Date.now() + i * 1000),
        });
        questionIds.push(question.id);
      }

      expect(
        (await service.getQuestions(queue.id)).priorityQueue.map((q) => q.id),
      ).toEqual(questionIds);
    });

    it('fetches questions in all groups', async () => {
      const queue = await QueueFactory.create();
      await createQuestionsEveryStatus(queue);
      const group1 = await QuestionGroupFactory.create({ queue });
      const g1q1 = await QuestionFactory.create({
        queue,
        groupable: true,
        group: group1,
      });
      const g1q2 = await QuestionFactory.create({
        queue,
        groupable: true,
        group: group1,
      });
      const group2 = await QuestionGroupFactory.create({ queue });
      const g2q1 = await QuestionFactory.create({
        queue,
        groupable: true,
        group: group2,
      });

      const recievedGroups = (await service.getQuestions(queue.id)).groups;

      expect(recievedGroups.length).toEqual(2);
      recievedGroups.forEach((group) => {
        if (group.id === group1.id) {
          expect(group.questions.length).toEqual(2);
          expect(group.questions.some((q) => q.id === g1q1.id)).toBeTruthy();
          expect(group.questions.some((q) => q.id === g1q2.id)).toBeTruthy();
        } else {
          expect(group.questions.length).toEqual(1);
          expect(group.questions.some((q) => q.id === g2q1.id)).toBeTruthy();
        }
      });
    });
  });

  describe('personalizeQuestions', () => {
    let queue;
    beforeEach(async () => {
      queue = await QueueFactory.create();
    });
    const personalize = (
      lqr: ListQuestionsResponse,
      userId: number,
      role: Role,
    ) => service.personalizeQuestions(queue.id, lqr, userId, role);

    it('does nothing if not a student', async () => {
      const user = await UserFactory.create();
      await QuestionFactory.create({
        queue,
        createdAt: new Date('2020-11-02T12:00:00.000Z'),
      });
      const lqr = await service.getQuestions(queue.id);
      expect(
        await service.personalizeQuestions(queue.id, lqr, user.id, Role.TA),
      ).toEqual(lqr);
    });

    it('adds yourQuestion for students with question in the queue', async () => {
      const user = await UserFactory.create();
      // Create a question but not in this queue
      await QuestionFactory.create({ creator: user });

      const blank: ListQuestionsResponse = {
        queue: [],
        priorityQueue: [],
        questionsGettingHelp: [],
        groups: [],
      };
      let lqr = await personalize(blank, user.id, Role.STUDENT);
      expect(lqr.yourQuestion).toEqual(undefined);

      // Create a question in this queue
      const question = await QuestionFactory.create({
        creator: user,
        queue,
      });
      lqr = await personalize(blank, user.id, Role.STUDENT);
      expect(lqr.yourQuestion.id).toEqual(question.id);
    });

    it('hides details of other students', async () => {
      const ours = await QuestionFactory.create({
        queue,
        createdAt: new Date('2020-11-02T12:00:00.000Z'),
        text: 'help us',
      });
      await QuestionFactory.create({
        queue,
        createdAt: new Date('2020-11-02T12:00:00.000Z'),
        text: 'help someone else',
      });

      const lqr = await personalize(
        await service.getQuestions(queue.id),
        ours.creatorId,
        Role.STUDENT,
      );
      expect(lqr).toMatchSnapshot();
    });
  });
});