Skip to content

Commit

Permalink
chore(test): Add E2E tests with multiple accounts
Browse files Browse the repository at this point in the history
Use --name option to reuse existing container

Move generic pool to global setup

improve account API

fix

fix parallel
  • Loading branch information
neet committed Apr 8, 2023
1 parent 56d8bdb commit 8b4b46f
Show file tree
Hide file tree
Showing 14 changed files with 383 additions and 81 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
DB_USER: mastodon
DB_NAME: mastodon
DB_PASS: password
MASTODON_CONTAINER: mastodon

services:
db:
Expand Down Expand Up @@ -69,6 +70,7 @@ jobs:
-d
-p 3000:3000
-p 4000:4000
--name ${{ env.MASTODON_CONTAINER }}
--env-file ./.github/.env.test
docker.io/neetshin/mastodon-dev:latest
bash -c "foreman start"
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"shortcode",
"subprotocol",
"subresource",
"Tootcli",
"trendable",
"typedoc",
"unassign",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unicorn": "^45.0.2",
"generic-pool": "^3.9.0",
"iterator-helpers-polyfill": "^2.2.8",
"jest": "^29.4.2",
"npm-run-all": "^4.1.5",
Expand Down
13 changes: 10 additions & 3 deletions test-utils/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
/* eslint-disable no-var */
import type { mastodon } from '../src';
import type { ClientPool, TokenPool } from './pools';

declare global {
var url: string;
var token: mastodon.v1.Token;
var instance: mastodon.v1.Instance;
var admin: mastodon.Client;
var clients: ClientPool;

/** Should only be used inside /test-utils */
var __misc__: {
url: string;
instance: mastodon.v1.Instance;
adminToken: mastodon.v1.Token;
tokens: TokenPool;
};
}
37 changes: 19 additions & 18 deletions test-utils/jest-global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,38 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { mastodon } from '../src';
import { fetchV1Instance, login } from '../src';
import { TokenPoolImpl } from './pools';

const BASE_URL = 'http://localhost:3000';
export default async (): Promise<void> => {
const url = 'http://localhost:3000';
const instance = await fetchV1Instance({ url });
const masto = await login({ url });

const createApp = async (): Promise<mastodon.v1.Application> => {
const masto = await login({ url: BASE_URL });
const app = await masto.v1.apps.create({
clientName: 'Masto.js',
redirectUris: 'urn:ietf:wg:oauth:2.0:oob',
scopes: 'read write follow push admin:read admin:write',
});
return app;
};

const createToken = async (
app: mastodon.v1.Client,
): Promise<mastodon.v1.Token> => {
const masto = await login({ url: BASE_URL });
const token = await masto.oauth.createToken({
const container = process.env.MASTODON_CONTAINER;
if (container == undefined) {
throw new Error('MASTODON_CONTAINER is not defined');
}

const tokenPool = new TokenPoolImpl(container, masto, app);

const adminToken = await masto.oauth.createToken({
grantType: 'password',
clientId: app.clientId!,
clientSecret: app.clientSecret!,
username: 'admin@localhost:3000',
password: 'mastodonadmin',
scope: 'read write follow push admin:read admin:write',
});
return token;
};

export default async (): Promise<void> => {
const app = await createApp();
globalThis.url = BASE_URL;
globalThis.token = await createToken(app);
globalThis.instance = await fetchV1Instance({ url: BASE_URL });
globalThis.__misc__ = {
url,
instance,
tokens: tokenPool,
adminToken,
};
};
13 changes: 7 additions & 6 deletions test-utils/jest-setup-after-env.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { createClient } from '../src';
import { ClientPoolImpl } from './pools';

jest.setTimeout(1000 * 60);

const { url, instance, token } = globalThis;

globalThis.admin = createClient({
url: url,
version: instance.version,
streamingApiUrl: instance.urls.streamingApi,
accessToken: token.accessToken,
url: __misc__.url,
version: __misc__.instance.version,
streamingApiUrl: __misc__.instance.urls.streamingApi,
accessToken: __misc__.adminToken.accessToken,
logLevel: 'info',
});

globalThis.clients = new ClientPoolImpl();
100 changes: 100 additions & 0 deletions test-utils/pools/client-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { mastodon } from '../../src';
import { createClient } from '../../src';

type UseFn<T> = (client: mastodon.Client) => Promise<T>;
type UseFnMany<T> = (client: mastodon.Client[]) => Promise<T>;

export type ClientPool = {
acquire(n?: 1 | undefined): Promise<mastodon.Client>;
acquire(n: number): Promise<mastodon.Client[]>;

release(token: mastodon.Client): Promise<void>;
release(tokens: mastodon.Client[]): Promise<void>;

use<T>(fn: UseFn<T>): Promise<T>;
use<T>(n: number, fn: UseFnMany<T>): Promise<T>;
};

export class ClientPoolImpl implements ClientPool {
private readonly clientToToken = new WeakMap<
mastodon.Client,
mastodon.v1.Token
>();

async acquire(n?: 1 | undefined): Promise<mastodon.Client>;
async acquire(n: number): Promise<mastodon.Client[]>;
async acquire(n = 1): Promise<mastodon.Client | mastodon.Client[]> {
if (n === 1) {
return this.acquireClient();
}

return Promise.all(
Array.from({ length: n }).map(() => this.acquireClient()),
);
}

async release(client: mastodon.Client): Promise<void>;
async release(clients: mastodon.Client[]): Promise<void>;
async release(clients: mastodon.Client | mastodon.Client[]): Promise<void> {
await (Array.isArray(clients)
? Promise.all(clients.map((client) => this.releaseClient(client)))
: this.releaseClient(clients));
}

async use<T>(fn: UseFn<T>): Promise<T>;
async use<T>(n: number, fn: UseFnMany<T>): Promise<T>;
async use<T>(
fnOrNumber: number | UseFn<T>,
fnOrUndefined?: UseFnMany<T>,
): Promise<T> {
if (typeof fnOrNumber === 'function' && fnOrUndefined == undefined) {
const fn = fnOrNumber;
const client = await this.acquire(1);

try {
return await fn(client);
} finally {
await this.release(client);
}
}

if (typeof fnOrNumber === 'number' && typeof fnOrUndefined === 'function') {
const n = fnOrNumber;
const fn = fnOrUndefined;
const clients = await this.acquire(n);

try {
return await fn(clients);
} finally {
await this.release(clients);
}
}

throw new Error('Invalid arguments');
}

private acquireClient = async () => {
const token = await __misc__.tokens.acquire();

const client = createClient({
url: __misc__.url,
version: __misc__.instance.version,
streamingApiUrl: __misc__.instance.urls.streamingApi,
accessToken: token.accessToken,
logLevel: 'info',
});

this.clientToToken.set(client, token);
return client;
};

private releaseClient = async (client: mastodon.Client) => {
const token = this.clientToToken.get(client);
if (token == undefined) {
return;
}

await globalThis.__misc__.tokens.release(token);
this.clientToToken.delete(client);
};
}
2 changes: 2 additions & 0 deletions test-utils/pools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './client-pool';
export * from './token-pool';
67 changes: 67 additions & 0 deletions test-utils/pools/token-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import crypto from 'node:crypto';

import type { Pool } from 'generic-pool';
import { createPool } from 'generic-pool';

import type { mastodon } from '../../src';
import type { Tootcli } from '../tootcli';
import { createTootcli } from '../tootcli';

export interface TokenPool {
acquire(): Promise<mastodon.v1.Token>;
release(token: mastodon.v1.Token): Promise<void>;
}

export class TokenPoolImpl implements TokenPool {
private readonly tootcli: Tootcli;
private readonly pool: Pool<mastodon.v1.Token>;

constructor(
container: string,
private readonly client: mastodon.Client,
private readonly app: mastodon.v1.Client,
) {
this.tootcli = createTootcli({ container });
this.pool = createPool(
{
create: async () => {
return this.create();
},
destroy: async () => {
return;
},
},
{ max: 10 },
);
}

acquire = (): Promise<mastodon.v1.Token> => {
return this.pool.acquire();
};

release = (token: mastodon.v1.Token): Promise<void> => {
return this.pool.release(token);
};

private readonly create = async (): Promise<mastodon.v1.Token> => {
const username = crypto.randomBytes(8).toString('hex');
const email = crypto.randomBytes(8).toString('hex') + '@example.com';

const { password } = await this.tootcli.accounts.create(username, {
email,
confirmed: true,
});

const token = await this.client.oauth.createToken({
grantType: 'password',
clientId: this.app.clientId!,
clientSecret: this.app.clientSecret!,
username: email,
password,
scope: 'read write follow push admin:read admin:write',
});

return token;
};
}
2 changes: 2 additions & 0 deletions test-utils/tootcli/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './tootcli';
export * from './tootcli-docker';
55 changes: 55 additions & 0 deletions test-utils/tootcli/tootcli-docker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import childProcess from 'node:child_process';
import util from 'node:util';

import type {
CreateAccountParams,
CreateAccountResult,
Tootcli,
} from './tootcli';

const exec = util.promisify(childProcess.exec);

const extractPassword = (stdout: string) => {
return stdout.match(/New password:\s(.+?)$/)?.[1];
};

const stringifyArguments = (
args: Record<string, string | number | boolean>,
): string => {
return Object.entries(args)
.map(([key, value]) => (value === true ? `--${key}` : `--${key}=${value}`))
.join(' ');
};

export type CreateTootcliParams = {
readonly container: string;
};

export const createTootcli = (params: CreateTootcliParams): Tootcli => {
const { container } = params;

return {
accounts: {
create: async (
username: string,
params: CreateAccountParams,
): Promise<CreateAccountResult> => {
const args = stringifyArguments(params);

const { stdout } = await exec(
[
`docker exec ${container}`,
`bash -c "RAILS_ENV=development bin/tootctl accounts create ${username} ${args}"`,
].join(' '),
);

const password = extractPassword(stdout.trim());
if (password == undefined) {
throw new Error("Couldn't extract password from stdout");
}

return { password };
},
},
};
};
17 changes: 17 additions & 0 deletions test-utils/tootcli/tootcli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type CreateAccountParams = {
readonly email: string;
readonly confirmed: boolean;
};

export type CreateAccountResult = {
password: string;
};

export interface Tootcli {
accounts: {
create: (
username: string,
params: CreateAccountParams,
) => Promise<CreateAccountResult>;
};
}

0 comments on commit 8b4b46f

Please sign in to comment.