/**
 * @license
 * Copyright 2020 The Closure Library Authors. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * @fileoverview Tests for create_closure_releases.ts.
 */

import * as assert from 'assert';

import {clientImplementationsForTesting, createClosureReleases} from './create_closure_releases';
import {Change, GitClient} from './git_client';
import {GitHubClient} from './github_client';

/** A fake GitHub API token to use in tests. */
const FAKE_TOKEN = 'my-github-token';
/** A fake GitHub Release URL to use in tests. */
const FAKE_RELEASE_URL = 'http://my-github-release';
/** A fake rollback hash used in several tests. */
const FAKE_ROLLBACK_HASH = '0123456701234567012345670123456701234567';

/**
 * An interface that describes data needed to fake GitClient prototype methods.
 */
interface FakeGitData {
  /** A fake commit hash. */
  hash: string;
  /** A fake commit message. */
  message: string;
  /** A fake commit body. Omit as a shorthand for setting it to ''. */
  body?: string;
  /** The value of the 'version' property in package.json at a fake commit. */
  pJsonVersion: string;
}

/**
 * A template tag that formats templated strings, under the
 * release-body-specific assumptions that (1) there are no string substitutions
 * and (2) a trailing newline is desired.
 */
function stripIndentForReleaseBody(
    strings: TemplateStringsArray, ...subs: unknown[]) {
  assert.strictEqual(strings.length - 1, subs.length);
  let output = '';
  for (let i = 0; i < subs.length; i++) {
    output += strings[i] + `${subs[i]}`;
  }
  output += strings[strings.length - 1];
  return output.trimStart().split('\n').map((str) => str.trim()).join('\n');
}

/**
 * Enable spies on GitClient and GitHub client, and provides fake data for
 * these functions to return when they are called.
 * @param fakeLatestReleaseHash The hash to return when
 *                          GitHubClient#getLatestRelease is called.
 * @param fakeGitCommits An array of objects that describe commit data returned
 *                    by GitClient instance methods.
 * @param fakeRecentDrafts An array of recent drafts to return when
 *     GitHubClient#getRecentDrafts is called.
 */
function spyOnClients(
    fakeLatestReleaseHash: string, fakeGitCommits: FakeGitData[],
    fakeRecentDrafts: Array<{tagName: string; id: number}> = []) {
  // Validate that no hashes are the same in `fakeGitCommits`.
  assert.strictEqual(
      new Set([...fakeGitCommits.map((x) => x.hash)]).size,
      fakeGitCommits.length);

  // Add a `body` to objects that are missing it.
  const gitCommits: Change[] =
      fakeGitCommits.map((commit) => Object.assign({body: ''}, commit));
  // Make GitClient#listCommits list commits in `fakeGitCommits`.
  spyOn(GitClient.prototype, 'listCommits').and.callFake(async ({from, to}) => {
    assert.strictEqual(to, 'HEAD');
    // + 1 because listCommits excludes `from`.
    return gitCommits.slice(
        gitCommits.findIndex((commit) => commit.hash === from) + 1);
  });

  // Make GitClient#getFile return a minimal package.json with data from
  // `fakeGitCommits`.
  spyOn(GitClient.prototype, 'getFile')
      .and.callFake(async (commitish, file) => {
        assert.strictEqual(file, 'package.json');
        const {pJsonVersion} =
            fakeGitCommits.find((commit) => commit.hash === commitish);
        return JSON.stringify({version: pJsonVersion});
      });

  // Make GitHubClient#getLatestReleaseTag return `fakeLatestReleaseHash`.
  spyOn(GitHubClient.prototype, 'getLatestReleaseTag')
      .and.returnValue(Promise.resolve(fakeLatestReleaseHash));
  // Make GitHubClient#getLatestRelease return `FAKE_RELEASE_URL`.
  spyOn(GitHubClient.prototype, 'draftRelease')
      .and.returnValue(Promise.resolve(FAKE_RELEASE_URL));
  // Make GitHubClient#getLatestRelease return `fakeRecentDrafts`.
  spyOn(GitHubClient.prototype, 'getRecentDrafts')
      .and.returnValue(Promise.resolve(fakeRecentDrafts));
}

describe('createClosureReleases', () => {
  beforeEach(() => {
    // For capturing logs and preventing logspam.
    spyOn(console, 'error');
  });

  // This isn't a real tested behavior of createClosureReleases, but it ensures
  // that spying on client prototype methods in other tests is safe.
  it('constructs exactly one instance of each client class', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
    ]);
    spyOn(clientImplementationsForTesting, 'GitClient').and.callThrough();
    spyOn(clientImplementationsForTesting, 'GitHubClient').and.callThrough();
    await createClosureReleases(FAKE_TOKEN);
    expect(clientImplementationsForTesting.GitClient).toHaveBeenCalledTimes(1);
    expect(clientImplementationsForTesting.GitHubClient)
        .toHaveBeenCalledTimes(1);
  });

  it('passes a Closure-library specific args and release token to GitHubClient',
     async () => {
       spyOnClients('00', [
         {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
       ]);
       spyOn(clientImplementationsForTesting, 'GitHubClient').and.callThrough();
       await createClosureReleases(FAKE_TOKEN);
       expect(clientImplementationsForTesting.GitHubClient)
           .toHaveBeenCalledOnceWith({
             owner: 'google',
             repo: 'closure-library',
             userAgent: 'Google-Closure-Library',
             token: FAKE_TOKEN,
           });
     });

  it('creates a release with a correctly-formatted tag', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {hash: '01', pJsonVersion: '20201010.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '01',
      body: 'No release notes.',
    });
  });

  it('displays a URL to the drafted GitHub Notes', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {hash: '01', pJsonVersion: '20201010.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledTimes(1);
    expect(console.error)
        .toHaveBeenCalledOnceWith(
            `Drafted release for v20201010 at ${FAKE_RELEASE_URL}`);
  });

  it('creates one release per new version bump', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {hash: '01', pJsonVersion: '20201010.0.0', message: ''},
      {hash: '02', pJsonVersion: '20201010.0.0', message: ''},
      {hash: '03', pJsonVersion: '20201011.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledTimes(2);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '01',
      body: 'No release notes.',
    });
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledWith({
      name: 'Closure Library v20201011',
      tagName: 'v20201011',
      commit: '03',
      body: 'No release notes.',
    });
  });

  it(`doesn't create a release when there are no new commits found`,
     async () => {
       spyOnClients('00', [
         {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
       ]);
       await createClosureReleases(FAKE_TOKEN);
       expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledTimes(0);
     });

  it('doesn\'t create a release when there are no version bumps found',
     async () => {
       spyOnClients('00', [
         {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
         {hash: '01', pJsonVersion: '20201009.0.0', message: ''},
       ]);
       await createClosureReleases(FAKE_TOKEN);
       expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledTimes(0);
     });

  it('properly handles 3 types of RELNOTES', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {
        hash: '01',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: Stuff happened',
      },
      {
        hash: '02',
        pJsonVersion: '20201009.0.0',
        message: 'commit 2\nRELNOTES[NEW]: New API',
      },
      {
        hash: '03',
        pJsonVersion: '20201009.0.0',
        message: 'commit 3\nRELNOTES[INC]: You are broken',
      },
      {
        hash: '04',
        pJsonVersion: '20201010.0.0',
        message: 'commit 4\nRELNOTES: A version bump',
      },
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '04',
      body: stripIndentForReleaseBody`
        **New Additions**
        * New API (02)

        **Backwards Incompatible Changes**
        * You are broken (03)

        **Other Changes**
        * Stuff happened (01)
        * A version bump (04)
      `,
    });
  });

  it('places multiple commits under the same release notes section',
     async () => {
       spyOnClients('00', [
         {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
         {
           hash: '01',
           pJsonVersion: '20201009.0.0',
           message: 'commit 1\nRELNOTES[NEW]: New API',
         },
         {
           hash: '02',
           pJsonVersion: '20201009.0.0',
           message: 'RELNOTES[NEW]: Even newer API',
         },
         {hash: '03', pJsonVersion: '20201010.0.0', message: ''},
       ]);
       await createClosureReleases(FAKE_TOKEN);
       expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
         name: 'Closure Library v20201010',
         tagName: 'v20201010',
         commit: '03',
         body: stripIndentForReleaseBody`
        **New Additions**
        * New API (01)
        * Even newer API (02)
      `,
       });
     });

  it('de-duplicates release notes with the same text and change type',
     async () => {
       spyOnClients('00', [
         {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
         {
           hash: '01',
           pJsonVersion: '20201009.0.0',
           message: 'RELNOTES[NEW]:Stuff happened',
         },
         {
           hash: '02',
           pJsonVersion: '20201009.0.0',
           message: 'RELNOTES[NEW]: Stuff happened',
         },
         {
           hash: '03',
           pJsonVersion: '20201009.0.0',
           message: 'RELNOTES:Stuff happened',
         },
         {
           hash: '04',
           pJsonVersion: '20201009.0.0',
           message: 'RELNOTES: Stuff happened',
         },
         {hash: '05', pJsonVersion: '20201010.0.0', message: ''},
       ]);
       await createClosureReleases(FAKE_TOKEN);
       expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
         name: 'Closure Library v20201010',
         tagName: 'v20201010',
         commit: '05',
         body: stripIndentForReleaseBody`
        **New Additions**
        * Stuff happened (01, 02)

        **Other Changes**
        * Stuff happened (03, 04)
      `,
       });
     });

  it(`doesn't write valid rollback or rolled back release notes`, async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {
        hash: FAKE_ROLLBACK_HASH,
        pJsonVersion: '20201009.0.0',
        message: 'RELNOTES[NEW]: Stuff happened',
      },
      {
        hash: '02',
        pJsonVersion: '20201009.0.0',
        message: `Automated rollback of commit ${FAKE_ROLLBACK_HASH}.`,
      },
      {hash: '03', pJsonVersion: '20201010.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '03',
      body: 'No release notes.',
    });
  });

  it('writes a release notes entry for invalid rollbacks under "Other Changes"',
     async () => {
       spyOnClients('00', [
         {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
         {
           hash: '01',
           pJsonVersion: '20201009.0.0',
           message: `Automated rollback of commit ${FAKE_ROLLBACK_HASH}.`,
         },
         {hash: '02', pJsonVersion: '20201010.0.0', message: ''},
       ]);
       await createClosureReleases(FAKE_TOKEN);
       expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
         name: 'Closure Library v20201010',
         tagName: 'v20201010',
         commit: '02',
         body: stripIndentForReleaseBody`
           **Other Changes**
           * __TODO(user):__ Rollback of ${FAKE_ROLLBACK_HASH} (01)
         `,
       });
     });

  it(`omits a rollback commit for an originally omitted change`, async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {
        hash: FAKE_ROLLBACK_HASH,
        pJsonVersion: '20201009.0.0',
        message: 'RELNOTES: n/a',
      },
      {
        hash: '02',
        pJsonVersion: '20201009.0.0',
        message: `Automated rollback of commit ${FAKE_ROLLBACK_HASH}.`,
      },
      {hash: '03', pJsonVersion: '20201010.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '03',
      body: 'No release notes.',
    });
  });

  it('doesn\'t write headers for empty sections in release notes', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {
        hash: '01',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: Stuff happened',
      },
      {
        hash: '02',
        pJsonVersion: '20201009.0.0',
        message: 'commit 2\nRELNOTES[NEW]: New API',
      },
      {hash: '03', pJsonVersion: '20201010.0.0', message: ''},
      {
        hash: '04',
        pJsonVersion: '20201010.0.0',
        message: 'commit 3\nRELNOTES[INC]: You are broken',
      },
      {hash: '05', pJsonVersion: '20201011.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledTimes(2);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '03',
      body: stripIndentForReleaseBody`
        **New Additions**
        * New API (02)

        **Other Changes**
        * Stuff happened (01)
      `,
    });
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledWith({
      name: 'Closure Library v20201011',
      tagName: 'v20201011',
      commit: '05',
      body: stripIndentForReleaseBody`
        **Backwards Incompatible Changes**
        * You are broken (04)
      `,
    });
  });

  it('ignores none or empty release notes', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {
        hash: '01',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES:',
      },
      {
        hash: '02',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: n/a',
      },
      {
        hash: '03',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: na',
      },
      {
        hash: '04',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: NA',
      },
      {
        hash: '05',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: Na',
      },
      {
        hash: '06',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: none',
      },
      {
        hash: '07',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: NONE',
      },
      {
        hash: '08',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1\nRELNOTES: NoNe',
      },
      {hash: '09', pJsonVersion: '20201010.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '09',
      body: 'No release notes.',
    });
  });

  it('escapes GitHub Markdown', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {
        hash: '01',
        pJsonVersion: '20201009.0.0',
        message: 'RELNOTES: *Stuff* #happened <div>',
      },
      {
        hash: '02',
        pJsonVersion: '20201009.0.0',
        message: 'RELNOTES: [Stuff] @happened _surely_',
      },
      {
        hash: '03',
        pJsonVersion: '20201009.0.0',
        message: 'RELNOTES: (Stuff) `happened`',
      },
      {hash: '04', pJsonVersion: '20201010.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '04',
      body: stripIndentForReleaseBody`**Other Changes**
        * \\*Stuff\\* \\#happened \\<div\\> (01)
        * \\[Stuff\\] \`@happened\` \\_surely\\_ (02)
        * \\(Stuff\\) \`happened\` (03)
      `,
    });
  });

  it('treats the commit body as part of the description', async () => {
    spyOnClients('00', [
      {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
      {
        hash: '01',
        pJsonVersion: '20201009.0.0',
        message: 'commit 1',
        body: 'RELNOTES: Stuff happened',
      },
      {hash: '02', pJsonVersion: '20201010.0.0', message: ''},
    ]);
    await createClosureReleases(FAKE_TOKEN);
    expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
      name: 'Closure Library v20201010',
      tagName: 'v20201010',
      commit: '02',
      body: stripIndentForReleaseBody`**Other Changes**
        * Stuff happened (01)
      `,
    });
  });

  it('updates a draft rather than create a new one with the same tag',
     async () => {
       spyOnClients(
           '00',
           [
             {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
             {hash: '01', pJsonVersion: '20201010.0.0', message: ''},
           ],
           [{tagName: 'v20201010', id: 314159}]);
       spyOn(clientImplementationsForTesting, 'GitHubClient').and.callThrough();
       await createClosureReleases(FAKE_TOKEN);
       expect(GitHubClient.prototype.getRecentDrafts).toHaveBeenCalled();
       expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
         name: 'Closure Library v20201010',
         tagName: 'v20201010',
         commit: '01',
         body: 'No release notes.',
         id: 314159,
       });
     });

  it('creates a new draft rather than update one with a different tag',
     async () => {
       spyOnClients(
           '00',
           [
             {hash: '00', pJsonVersion: '20201009.0.0', message: ''},
             {hash: '01', pJsonVersion: '20201011.0.0', message: ''},
           ],
           [{tagName: 'v20201010', id: 314159}]);
       spyOn(clientImplementationsForTesting, 'GitHubClient').and.callThrough();
       await createClosureReleases(FAKE_TOKEN);
       expect(GitHubClient.prototype.getRecentDrafts).toHaveBeenCalled();
       expect(GitHubClient.prototype.draftRelease).toHaveBeenCalledOnceWith({
         name: 'Closure Library v20201011',
         tagName: 'v20201011',
         commit: '01',
         body: 'No release notes.',
       });
     });
});
