diff --git a/docs/database/query-builder.mdx b/docs/database/query-builder.mdx index e53466fa..bcdca762 100644 --- a/docs/database/query-builder.mdx +++ b/docs/database/query-builder.mdx @@ -6,6 +6,8 @@ description: See how to use the Athenna database query builder. # Database: Query Builder +See how to use the Athenna database query builder. + ## Introduction Athenna database query builder provides a convenient, fluent interface diff --git a/docs/database/seeding.mdx b/docs/database/seeding.mdx index 799f1112..451d82d7 100644 --- a/docs/database/seeding.mdx +++ b/docs/database/seeding.mdx @@ -6,6 +6,8 @@ description: See how to create and run database seeders. # Database: Seeding +See how to create and run database seeders. + ## Introduction Athenna includes the ability to seed your database with data using diff --git a/docs/orm/annotations.mdx b/docs/orm/annotations.mdx new file mode 100644 index 00000000..6405568a --- /dev/null +++ b/docs/orm/annotations.mdx @@ -0,0 +1,229 @@ +--- +title: Annotations +sidebar_position: 5 +description: Check all available ORM annotations and it options. +--- + +# ORM: Annotations + +Check all available ORM annotations and it options. + +## `@Column()` + +The `@Column()` annotations marks a model property as a database column: + +```typescript +import { Column, BaseModel } from '@athenna/database' + +export class Flight extends BaseModel { + @Column() + public id: number + + @Column() + public from: string + + @Column() + public to: string + + @Column({ isCreateDate: true }) + public createdAt: Date + + @Column({ isUpdateDate: true }) + public updatedAt: Date +} +``` + +You can also define any of the following optional properties: + + +#### `name` + +Map which will be the name of your column in database: + +```typescript +@Column({ name: 'my_name' }) +public name: string +``` + +The default value of this property will be the name of +your class property as **camelCase**. + +#### `type` + +Map the type of your column. This property is usefull +only to synchronize your model with database: + +```typescript +@Column({ type: Number }) +public id: string +``` + +By default the type of your model will be set as the +type of your class property, in the example above, if +we remove the `type` property, it would automatically +be set as `String`. + +#### `length` + +Map the column length in database. This property is +usefull only when synchronizing your model with database: + +```typescript +@Column({ length: 10 }) +public name: string +``` + +#### `defaultTo` + +This property doesn't change the behavior in your database, +they are used only when the class property is undefined or +null before running your model `create()`, `createMany()`, +`update()` and `createOrUpdate()` methods: + +```typescript +@Column({ defaultTo: null }) +public deletedAt: Date +``` + +:::warn + +The value set to `defaulTo` property will only be used when +the value for the specified column was not provided when calling +the above methods and also when it was not set in static `attributes()` +method of your model. + +::: + +#### `isPrimary` + +Set if the column is a primary key: + +```typescript +@Column({ isPrimary: true }) +public id: number +``` + +#### `isHidden` + +Set if the column should be hidden when retrieving it from database: + +```typescript +@Column({ isHidden: true }) +public password: string +``` + +#### `isUnique` + +Set if the column needs to have a unique value in database: + +```typescript +@Column({ isUnique: true }) +public email: string +``` + +:::note + +If you try to create duplicated values Athenna will throw an +exception until it gets in your database. This means that you +migration could have or not the unique index defined + +::: + +#### `isNullable` + +Set if the column is nullable or not: + +```typescript +@Column({ isNullable: false }) +public name: string +``` + +:::note + +Just like `isUnique` property, if `isNullable` is set to false +and you try to create a model with null or undefined `name`, it +will throw an exception. + +::: + + +#### `isIndex` + +Set if the column is an index: + +```typescript +@Column({ isIndex: true }) +public email: string +``` + +#### `isSparse` + +Set if the column is an index sparse: + +```typescript +@Column({ isSparse: true }) +public email: string +``` + +#### `persist` + +Set if the column should be persist in database +or not. If set as `false`, Athenna will remove this +column from operations like create or update, but it +will still me available in listing operations: + +```typescript +@Column({ persist: false }) +public name: string +``` + +#### `isCreateDate` + +Set if the column is a createdAt column. If this option +is `true`, Athenna will automatically set a `new Date()` +value in the column when creating it: + +```typescript +@Column({ isCreateDate: true }) +public createdAt: Date +``` + +#### `isUpdateDate` + +Set if the column is an updatedAt column. If this option +is `true`, Athenna will automatically set a `new Date()` +value in the column when creating it: + +```typescript +@Column({ isUpdateDate: true }) +public updatedAt: Date +``` + +#### `isDeleteDate` + +Set if the column is a deletedAt column and also if the model +is using soft delete approach. If this option is `true`, Athenna +will automatically set a `new Date()` value in the column when +deleting it: + +```typescript +@Column({ isDeleteDate: true }) +public deletedAt: Date +``` + +## `@HasOne()` + +Comming soon... + +## `@HasMany()` + +Comming soon... + +## `@BelongsTo()` + +Comming soon... + +## `@BelongsToMany()` + +Comming soon... + diff --git a/docs/orm/extending-models.mdx b/docs/orm/extending-models.mdx new file mode 100644 index 00000000..f435868b --- /dev/null +++ b/docs/orm/extending-models.mdx @@ -0,0 +1,13 @@ +--- +title: Extending Models +sidebar_position: 4 +description: See how to extend models implementations in Athenna Framework. +--- + +# ORM: Extending Models + +See how to extend models implementations in Athenna Framework. + +## Introduction + +Coming soon... \ No newline at end of file diff --git a/docs/orm/factories.mdx b/docs/orm/factories.mdx new file mode 100644 index 00000000..fd65f35e --- /dev/null +++ b/docs/orm/factories.mdx @@ -0,0 +1,13 @@ +--- +title: Factories +sidebar_position: 6 +description: See how to factory fake models in Athenna Framework. +--- + +# ORM: Factories + +See how to factory fake models in Athenna Framework. + +## Introduction + +Coming soon... \ No newline at end of file diff --git a/docs/orm/getting-started.mdx b/docs/orm/getting-started.mdx index 23d76a11..1259ee0a 100644 --- a/docs/orm/getting-started.mdx +++ b/docs/orm/getting-started.mdx @@ -6,6 +6,8 @@ description: See how to create models in Athenna Framework. # ORM: Getting Started +See how to create models in Athenna Framework. + ## Introduction Athenna has an object-relational mapper (ORM) that makes it enjoyable @@ -65,182 +67,8 @@ export class Flight extends BaseModel { } ``` -### Column options - -#### `name` - -Map which will be the name of your column in database: - -```typescript -@Column({ name: 'my_name' }) -public name: string -``` - -The default value of this property will be the name of -your class property as **camelCase**. - -#### `type` - -Map the type of your column. This property is usefull -only to synchronize your model with database: - -```typescript -@Column({ type: Number }) -public id: string -``` - -By default the type of your model will be set as the -type of your class property, in the example above, if -we remove the `type` property, it would automatically -be set as `String`. - -#### `length` - -Map the column length in database. This property is -usefull only when synchronizing your model with database: - -```typescript -@Column({ length: 10 }) -public name: string -``` - -#### `defaultTo` - -This property doesn't change the behavior in your database, -they are used only when the class property is undefined or -null before running your model `create()`, `createMany()`, -`update()` and `createOrUpdate()` methods: - -```typescript -@Column({ defaultTo: null }) -public deletedAt: Date -``` - -:::warn - -The value set to `defaulTo` property will only be used when -the value for the specified column was not provided when calling -the above methods and also when it was not set in static `attributes()` -method of your model. - -::: - -#### `isPrimary` - -Set if the column is a primary key: - -```typescript -@Column({ isPrimary: true }) -public id: number -``` - -#### `isHidden` - -Set if the column should be hidden when retrieving it from database: - -```typescript -@Column({ isHidden: true }) -public password: string -``` - -#### `isUnique` - -Set if the column needs to have a unique value in database: - -```typescript -@Column({ isUnique: true }) -public email: string -``` - -:::note - -If you try to create duplicated values Athenna will throw an -exception until it gets in your database. This means that you -migration could have or not the unique index defined - -::: - -#### `isNullable` - -Set if the column is nullable or not: - -```typescript -@Column({ isNullable: false }) -public name: string -``` - -:::note - -Just like `isUnique` property, if `isNullable` is set to false -and you try to create a model with null or undefined `name`, it -will throw an exception. - -::: - - -#### `isIndex` - -Set if the column is an index: - -```typescript -@Column({ isIndex: true }) -public email: string -``` - -#### `isSparse` - -Set if the column is an index sparse: - -```typescript -@Column({ isSparse: true }) -public email: string -``` - -#### `persist` - -Set if the column should be persist in database -or not. If set as `false`, Athenna will remove this -column from operations like create or update, but it -will still me available in listing operations: - -```typescript -@Column({ persist: false }) -public name: string -``` - -#### `isCreateDate` - -Set if the column is a createdAt column. If this option -is `true`, Athenna will automatically set a `new Date()` -value in the column when creating it: - -```typescript -@Column({ isCreateDate: true }) -public createdAt: Date -``` - -#### `isUpdateDate` - -Set if the column is an updatedAt column. If this option -is `true`, Athenna will automatically set a `new Date()` -value in the column when creating it: - -```typescript -@Column({ isUpdateDate: true }) -public updatedAt: Date -``` - -#### `isDeleteDate` - -Set if the column is a deletedAt column and also if the model -is using soft delete approach. If this option is `true`, Athenna -will automatically set a `new Date()` value in the column when -deleting it: - -```typescript -@Column({ isDeleteDate: true }) -public deletedAt: Date -``` +For more information about model options visit the [`@Column()` +annotation documentation section](/docs/orm/annotations/#column). ## Model conventions @@ -409,5 +237,492 @@ flights.forEach(flight => console.log(flight.name)) ### Building queries -Coming soon... +The model `findMany()` method will return all the results in the model's +table. However, since each model serves as a query builder, you may +invoke the `query()` method first and add additional constraints to +queries and then invoke the `findMany()` method to retrieve the results: + +```typescript +const flights = await Flight.query() + .where('active', 1) + .orderBy('name') + .limit(10) + .findMany() +``` + +:::tip + +Since models are query builders, you should review all the methods +provided by [`Athenna's query builder`](https://athenna.io/docs/database/query-builder). +You may use any of these methods when writing your model queries. + +::: + +### Hidding fields + +Sometimes you might need to hide some sensitive field from your model +queries, to do so, you can set the `isHidden` property to true in your +`@Column()` annotation: + +```typescript +import { BaseModel } from '@athenna/database' + +export class User extends BaseModel { + @Column({ isHidden: true }) + public password: string + + /*...*/ +} +``` + +Everytime you call the `query()` method of your models, Athenna will +automatically select all the columns from your model but never the +ones where the `isHidden` property is true. + +#### Retrieve hidden fields + +If you wish to get all the hidden fields for a specify use case you +can use the `withHidden()` method of the query builder: + +```typescript +const { password } = await User.query() + .withHidden() + .find() +``` + +## Pagination + +The Athenna models also has a `paginate()` method that works exact like +the [`paginate method from the query builder:`](/docs/database/query-builder#id-pagination) + +```typescript +const page = 0 +const limit = 10 +const resourceUrl = '/flights' +const where = { active: 1 } + +const { + data, + meta, + links +} = await Flight.paginate(page, limit, resourceUrl, where) +``` + +You can also use the `paginate()` method when working with the +`query()` method: + +```typescript +const page = 0 +const limit = 10 +const resourceUrl = '/flights' + +const { data, meta, links } = await Flight.query() + .where({ active: 1 }) + .paginate(page, limit, resourceUrl) +``` + +## Collections + +As we have seen, the models method `findMany()` retrieve multiple +records from the database. However, the Athenna model has a +`collection()` method that will also retrieve multiple records from +the database but return it as an instance of the +[`Collection`](/docs/digging-deeper/collections) class. + +The Collection class provides a variety of helpful methods for +interacting with data collections. For example, the `reject()` method +may be used to remove models from a collection based on the results +of an invoked closure: + +```typescript +const flights = await Flight.collection({ destination: 'Paris' }) + +const availableFlights = flights.reject(flight => flight.cancelled) +``` + +## Retrieve single models & Aggregates + +In addition to retrieving all the records matching a given query, you +may also retrieve single records using the `find()` method. Instead of +returning an array or collection of models, this method return a single +model instance: + +```typescript +const flight = await Flight.find({ id: 1 }) + +const flight = await Flight.find({ active: 1 }) + +const flight = await Flight.query().where('active', 1).find() +``` + +Sometimes you may wish to perform some other action if no results are +found. The `findOr()` method will return a single model instance or, +if no results are found, execute the given closure. The value returned +by the closure **will be considered the result of the method:** + +```typescript +const flight = await Flight.findOr({ id: 1 }, async () => { + // ... +}) + +const flight = await Flight.query() + .where('legs', '>', 3) + .findOr(async () => { + // ... + }) +``` + +### Not found exceptions + +Sometimes you may wish to throw an exception if a model is not found. +This is particularly useful in routes or controllers. The `findOrFail()` +method will retrieve the first result of the query; however, if no +result is found, an `NotFoundDataException` will be thrown: + +```typescript +const flight = await Flight.findOrFail({ id: 1 }) + +const flight = await Flight.query().where('legs', '>', 3).findOrFail() +``` + +### Retrieving aggregates + +When interacting with models, you may also use the `count()`, `sum()`, +`max()`, and other aggregate methods provided by the +[`Athenna query builder`](/docs/database/query-builder). +As you might expect, these methods return a scalar value instead of +a model instance: + +```typescript +const count = await Flight.query().where('active', 1).count() + +const max = await Flight.query().where('active', 1).max('price') +``` + +:::warning + +Aggregate methods will not exist directly in your models, you will +always need to call the `query()` method first and then execute it +using one of then. + +::: + +## Inserting & Updating models + +### Inserts + +Of course, when using the models, we don't only need to retrieve then +from the database. We also need to insert new records. Thankfully, the +models makes it simple. To insert a new record into the database, you +should instantiate a new model instance and set attributes on the model. +Then, call the `save()` method on the model instance: + +```typescript +const flight = new Flight() + +flight.name = 'Brazil to Ukraine' + +await flight.save() +``` + +In this example, we assign the `name` field to the name attribute of +the `#app/models/Flight` model instance. When we call the `save()` +method, a record will be inserted into the database. The model's +`createdAt` and `updatedAt` timestamps will automatically be set +when the `save()` method is called, so there is no need to set them +manually. + +Alternatively, you may use the `create()` method to "save" a new model +using a single statement. The inserted model instance will be returned +to you by the `create()` method: + +```typescript +const flight = await Flight.create({ name: 'Brazil to Angola' }) +``` + +However, we highly recommend that before using the `create()` method, +you specify the `persist` field as `false` in fields you dont want to +be persisted. This property will help your models to get protected against mass assignment +vulnerabilities. To learn more about mass assignment, please consult +the [`mass assignment documentation.`](/docs/orm/getting-started#mass-assignment) + +### Updates + +The `save()` method may also be used to update models that already exist +in the database. To update a model, you should retrieve it and set any +attributes you wish to update. Then, you should call the model's `save()` +method. Again, the `updatedAt` timestamp will automatically be updated, +so there is no need to manually set its value: + +```typescript +const flight = await Flight.query() + .where({ id: 1 }) + .find() + +flight.name = 'Paris to London' + +await flight.save() +``` + +#### Mass updates + +Updates can also be performed against models that match a given query. +In this example, all flights that are `active` and have a `destination` +of `San Diego` will be marked as delayed: + +```typescript +await Flight.query() + .where('active', 1) + .where('destination', 'San Diego') + .update({ delayed: 1 }) +``` + +The `update()` method expects a record of columns and value pairs +representing the columns that should be updated. The `update()` method +will always return one instance of your model if your query only modifies +one value. If you query modifies more than one the `update()` method will +return an array of your models instance. + +### Mass assignment + +You may use the `create()` method to "save" a new model using a single +statement. The inserted model instance will be returned to you by the +method: + +```typescript +const flight = await Flight.create({ + name: 'London to Korea', +}) +``` + +However, before using the `create()` method, we extremely recommend +you to specify which fields on your model class should not be persisted +in database. This property are will help you to stay protected against +mass assignment vulnerabilities. + +A mass assignment vulnerability occurs when a user passes an unexpected +field using some object and that field changes a column in your +database that you did not expect. For example, a malicious user might +send an `isAdmin` parameter through an HTTP request, which is then passed +to your model's `create()` method, allowing the user to escalate +themselves to an administrator. + +So, to get started, you should define which model properties you dont +want to be persisted in database. You may do this using by setting the +`persist` property to false in your `@Column()` annotation. For example, +let's make the `isAdmin` attribute a filed that could not be persisted: + +```typescript +import { BaseModel } from '@athenna/database' + +export class Flight extends BaseModel { + @Column() // By default persist is already `true` + public name: string + + @Colum({ persist: false }) + public isAdmin: boolean + + /*...*/ +} +``` + +Once you have specified which attributes are mass assignable or not, +you may use the `create()` method to insert a new record in the database. The +`create()` method returns the newly created model instance: + +```typescript +const flight = await Flight.create({ name: 'London to Paris' }) +``` + +#### Allowing mass assignment for some calls + +You can also allow mass assignment when calling your `create()`, +`createMany()`, `createOrUpdate()` and `update()` methods: + +```typescript +const data = { name: 'Brazil to Mexico' } +const where = { active: 1 } +const cleanPersist = false + +await Flight.create(data, cleanPersist) +await Flight.query().create(data, cleanPersist) + +await Flight.createMany([data], cleanPersist) +await Flight.query().createMany([data], cleanPersist) + +await Flight.createOrUpdate(where, data, cleanPersist) +await Flight.query().createOrUpdate(where, data, cleanPersist) + +await Flight.update(where, data, cleanPersist) +await Flight.query().update(where, data, cleanPersist) +``` + +### Insert or update (Upserts) + +Occasionally, you may need to update an existing model or create a +new model if no matching model exists. The `createOrUpdate()` method +will update the model if some record is found by the query that you +have built, otherwise the record will be created. + +In the example below, if a flight exists with a `departure` location +of `Oakland` and a `destination` location of `San Diego`, its `price` +and `discounted` columns will be updated. If no such flight exists, +a new flight will be created: +```typescript +const where = { + departure: 'Oakland', + destination: 'San Diego' +} +const data = { + price: 99, + discounted: 1, + departure: 'Oakland', + destination: 'San Diego' +} + +await Flight.createOrUpdate(where, data) +``` + +Or you can use the query builder instead: + +```typescript +await Flight.query() + .where('departure', 'Oakland') + .where('destination', 'San Diego') + .createOrUpdate({ + price: 99, + discounted: 1, + departure: 'Oakland', + destination: 'San Diego' + }) +``` + +## Deleting models + +To delete a model, you may call the `delete()` method on the model +instance: + +```typescript +import { Flight } from '#app/models/Flight' + +const where = { id: 1 } +await Flight.delete(where) +``` + +You can also delete an instance directly: + +```typescript +import { Flight } from '#app/models/Flight' + +const where = { id: 1 } +const flight = await Flight.find(where) + +await flight.delete() +``` + +You may call the `truncate()` method to delete all the model's associated +database records. The `truncate` operation will also reset any +auto-incrementing IDs on the model's associated table: + +```typescript +await Flight.truncate() +``` + +## Soft deleting + +In addition to actually removing records from your database, the ORM +can also "soft delete" models. When models are soft deleted, they are +not actually removed from your database. Instead, a `deletedAt` +attribute is set on the model indicating the date and time at which +the model was "deleted". To enable soft deletes for a model, you just +need to set up one column with `isDeleteDate` as true: + +```typescript +export class Flight extends BaseModel { + @Column({ isDeleteDate: true }) + public deletedAt: Date + + /*...*/ +} +``` + +Now, when you call the `delete()` method on the model, Athenna will +update your model with the current date and time in your `deletedAt` +column. However, the model's database record will be left in the table. +When querying a model that uses soft deletes, the soft deleted models +will automatically be excluded from all query results: + +```typescript +const where = { id: 1 } +const flight = await Flight.find(where) + +await flight.delete() + +// The flight with id = 1 will not be inside the above array. +const flights = await Flight.findMany() +``` + +To determine if a given model instance has been soft deleted, you may +use the `isTrashed()` method: + +```typescript +if (flight.isTrashed()) { + // +} +``` + +### Restoring soft delete models + +Sometimes you may wish to "un-delete" a soft deleted model. To restore +a soft deleted model, you may call the `restore()` method on a model +instance. The `restore()` method will set the model's `deletedAt` column +to `null`: + +```typescript +await flight.restore() +``` + +You may also use the `restore()` method in a query to restore multiple +models: + +```typescript +const flights = await Flight.query() + .where('airlineId', 1) + .restore() +``` + +### Permanently deleting a model + +Sometimes you may need to truly remove a model from your database. You +may use the `delete()` method with a `true` value in the first argument +to permanently remove a soft deleted model from the database table: + +```typescript +const force = true +await flight.delete(force) +``` + +### Querying soft delete models + +As noted above, soft deleted models will automatically be excluded from +query results. However, you may force soft deleted models to be included +in a query's results by calling the `withTrashed()` method on the query: + +```typescript +const flights = await Flight.query() + .withTrashed() + .where('airlineId', 1) + .findMany() +``` + +#### Retrieving only soft deleted models + +The `onlyTrashed()` method will retrieve only soft deleted models: + +```typescript +const flights = await Flight.query() + .onlyTrashed() + .where('airlineId', 1) + .findMany() +``` diff --git a/docs/orm/query-builder.mdx b/docs/orm/query-builder.mdx new file mode 100644 index 00000000..38eb89c6 --- /dev/null +++ b/docs/orm/query-builder.mdx @@ -0,0 +1,13 @@ +--- +title: Query Builder +sidebar_position: 2 +description: See how to retrieve, insert, update and delete models using the query builder in Athenna Framework. +--- + +# ORM: Query Builder + +See how to retrieve, insert, update and delete models using the query builder in Athenna Framework. + +## Introduction + +Coming soon... \ No newline at end of file diff --git a/docs/orm/relationships.mdx b/docs/orm/relationships.mdx new file mode 100644 index 00000000..cf87a614 --- /dev/null +++ b/docs/orm/relationships.mdx @@ -0,0 +1,13 @@ +--- +title: Relationships +sidebar_position: 3 +description: See how to create relations between models in Athenna Framework. +--- + +# ORM: Relationships + +See how to create relations between models in Athenna Framework. + +## Introduction + +Coming soon... \ No newline at end of file diff --git a/docs/orm/soft-deleting.mdx b/docs/orm/soft-deleting.mdx new file mode 100644 index 00000000..c36a6da6 --- /dev/null +++ b/docs/orm/soft-deleting.mdx @@ -0,0 +1,13 @@ +--- +title: Soft Deleting +sidebar_position: 7 +description: See how to soft delete models in Athenna Framework. +--- + +# ORM: Soft Deleting + +See how to soft delete models in Athenna Framework. + +## Introduction + +Coming soon... \ No newline at end of file diff --git a/docs/testing/annotations.mdx b/docs/testing/annotations.mdx new file mode 100644 index 00000000..6fe9e8de --- /dev/null +++ b/docs/testing/annotations.mdx @@ -0,0 +1,292 @@ +--- +title: Annotations +sidebar_position: 4 +description: Check all available testing annotations and it options. +--- + +# Annotations + +Check all available testing annotations and it options. + +## `@Retry()` + +Define that a test should be retried if it fails: + +```typescript +import { Test, Retry, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Retry(2) 👈 // Retry the test 2 times + public async test({ assert }: Context) { + assert.equal(1 + 1, 2) + } +} +``` + +:::tip + +You can get the number of retries and in which retry +attempt you are by the `test.options.retries` and +`test.options.retryAttempt` properties + +```typescript +import { Log } from '@athenna/logger' +import { Test, TestCase, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Retry(2) + public async test({ test, assert }: Context) { + Log.info('Retrying attempt:', test.options.retryAttempt) + + assert.equal(1 + 1, 2) + assert.equal(test.options.retries, 2) + } +} +``` + +::: + +## `@Skip()` + +Skip the test when executing `test` command: + +```typescript +import { Test, Skip, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Skip() + public async willNotRun({ assert }: Context) { + assert.equal(1 + 1, 2) + } + + @Test() + @Skip('Skipped because of some reason') + public async willNotRunAlso({ assert }: Context) { + assert.equal(1 + 1, 2) + } +} +``` + +## `@Pin()` + +When running `test` command, only pinned tests will run +if at least one test is pinned: + +```typescript +import { Test, Pin, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Pin() + public async willRun({ assert }: Context) { + assert.equal(1 + 1, 2) + } + + @Test() + @Pin() + public async willRunAlso({ assert }: Context) { + assert.equal(1 + 1, 2) + } + + @Test() + public async willNotRun({ assert }: Context) { + assert.equal(1 + 1, 2) + } +} +``` + +:::tip + +If you want to run specific tests you can also use the +`--tests` option of `test` command: + +```shell +node artisan test --tests willRun --tests willRunAlso +``` + +::: + +## `@Fails()` + +Define that a test is expected to fail: + +```typescript +import { Test, Fails, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Fails() + public async test({ assert }: Context) { + assert.equal(1 + 1, 1) 👈 // Fail + } +} +``` + +## `@TestCase()` + +Define the dataset for the test case. Your test will be +invoked for each test case defined: + +```typescript +import { Test, TestCase, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @TestCase('lenon@athenna.io') + @TestCase('txsoura@athenna.io') + public async test({ assert }: Context, email: string) { + assert.isTrue(email.includes('@athenna.io')) + } +} +``` + +:::tip + +You can get all the test cases defined by the `test.dataset` +property: + +```typescript +import { Test, TestCase, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @TestCase('lenon@athenna.io') + @TestCase('txsoura@athenna.io') + public async test({ test, assert }: Context, email: string) { + assert.isTrue(email.includes('@athenna.io')) + assert.deepEqual(test.dataset, [ + 'txsoura@athenna.io', + 'lenon@athenna.io' + ]) + } +} +``` + +::: + +## `@Timeout()` + +Set a timeout in MS for that specific test only: + +```typescript +import { Test, Timeout, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Timeout(10000) 👈 // 10 seconds + public async test({ assert }: Context) { + assert.equal(1 + 1, 2) + } +} +``` + +## `@DisableTimeout()` + +Disable the timeout for a specific test: + +```typescript +import { Test, DisableTimeout, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @DisableTimeout(10000) 👈 // 10 seconds + public async test({ assert }: Context) { + assert.equal(1 + 1, 2) + } +} +``` + +## `@Tags()` + +Add tags to a test. Tags can be used to filter tests +when running `test` command: + +```typescript +import { Test, Tags, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Tags(['run']) + public async willRun({ assert }: Context) { + assert.equal(1 + 1, 2) + } + + @Test() + @Tags(['run']) + public async willRunAlso({ assert }: Context) { + assert.equal(1 + 1, 2) + } + + @Test() + @Tags(['dont-run']) + public async willNotRun({ assert }: Context) { + assert.equal(1 + 1, 2) + } +} +``` + +Now you can select which tests to run by the tags: + +```shell +node artisan test --tags run +``` + +## `@Cleanup()` + +Create a cleanup function with the purpose to +clean the state created by your test. This function +will always be executed, even if your test fails: + +```typescript +import { Test, Cleanup, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Cleanup(() => Config.set('app.name', 'Athenna')) + public async test({ assert }: Context) { + Config.set('app.name', 'MyApp') + + assert.equal(Config.get('app.name'), 'MyApp') + } +} +``` + +## `@Setup()` + +`@Setup()` annotation works like `@BeforeEach()` +but for a specific test. The function you define +inside will be called before the test starts running: + +```typescript +import { Test, Setup, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Setup(() => Config.set('app.name', 'MyApp')) + public async test({ assert }: Context) { + assert.equal(Config.get('app.name'), 'MyApp') + } +} +``` + +## `@Teardown()` + +`@Teardown()` annotation works like `@AfterEach()` +but for a specific test. The function you define +inside will be called after the test finish running: + +```typescript +import { Test, Setup, Teardown, type Context } from '@athenna/test' + +export default class ExampleTest { + @Test() + @Setup(() => Config.set('app.name', 'MyApp')) + @Teardown(() => Config.set('app.name', 'Athenna')) + public async test({ assert }: Context) { + assert.equal(Config.get('app.name'), 'MyApp') + } +} +``` diff --git a/docs/testing/getting-started.mdx b/docs/testing/getting-started.mdx index 53965727..b7021bbd 100644 --- a/docs/testing/getting-started.mdx +++ b/docs/testing/getting-started.mdx @@ -313,264 +313,8 @@ will be worth your time 😎. Along with the annotations seen above, Athenna ships with other useful annotations that can be used to customize how -your test is going to behave: - -#### `@Retry()` - -Define that a test should be retried if it fails: - -```typescript -import { Test, Retry } from '@athenna/test' - -@Test() -@Retry(2) 👈 // Retry the test 2 times -public async test({ assert }: Context) { - assert.equal(1 + 1, 2) -} -``` - -:::tip - -You can get the number of retries and in which retry -attempt you are by the `test.options.retries` and -`test.options.retryAttempt` properties - -```typescript -import { Log } from '@athenna/logger' -import { Test, TestCase } from '@athenna/test' - -@Test() -@Retry(2) -public async test({ test, assert }: Context) { - Log.info('Retrying attempt:', test.options.retryAttempt) - - assert.equal(1 + 1, 2) - assert.equal(test.options.retries, 2) -} -``` - -::: - -#### `@Skip()` - -Skip the test when executing `test` command: - -```typescript -import { Test, Skip } from '@athenna/test' - -@Test() -@Skip() -public async willNotRun({ assert }: Context) { - assert.equal(1 + 1, 2) -} - -@Test() -@Skip('Skipped because of some reason') -public async willNotRunAlso({ assert }: Context) { - assert.equal(1 + 1, 2) -} -``` - -#### `@Pin()` - -When running `test` command, only pinned tests will run -if at least one test is pinned: - -```typescript -import { Test, Pin } from '@athenna/test' - -@Test() -@Pin() -public async willRun({ assert }: Context) { - assert.equal(1 + 1, 2) -} - -@Test() -@Pin() -public async willRunAlso({ assert }: Context) { - assert.equal(1 + 1, 2) -} - -@Test() -public async willNotRun({ assert }: Context) { - assert.equal(1 + 1, 2) -} -``` - -:::tip - -If you want to run specific tests you can also use the -`--tests` option of `test` command: - -```shell -node artisan test --tests="willRun,willRunAlso" -``` - -::: - -#### `@Fails()` - -Define that a test is expected to fail: - -```typescript -import { Test, Fails } from '@athenna/test' - -@Test() -@Fails() -public async test({ assert }: Context) { - assert.equal(1 + 1, 1) 👈 // Fail -} -``` - -#### `@TestCase()` - -Define the dataset for the test case. Your test will be -invoked for each test case defined: - -```typescript -import { Test, TestCase } from '@athenna/test' - -@Test() -@TestCase('lenon@athenna.io') -@TestCase('txsoura@athenna.io') -public async test({ assert }: Context, email: string) { - assert.isTrue(email.includes('@athenna.io')) -} -``` - -:::tip - -You can get all the test cases defined by the `test.dataset` -property: - -```typescript -import { Test, TestCase } from '@athenna/test' - -@Test() -@TestCase('lenon@athenna.io') -@TestCase('txsoura@athenna.io') -public async test({ test, assert }: Context, email: string) { - assert.isTrue(email.includes('@athenna.io')) - assert.deepEqual(test.dataset, [ - 'txsoura@athenna.io', - 'lenon@athenna.io' - ]) -} -``` - -::: - -#### `@Timeout()` - -Set a timeout in MS for that specific test only: - -```typescript -import { Test, Timeout } from '@athenna/test' - -@Test() -@Timeout(10000) 👈 // 10 seconds -public async test({ assert }: Context) { - assert.equal(1 + 1, 2) -} -``` - -#### `@DisableTimeout()` - -Disable the timeout for a specific test: - -```typescript -import { Test, DiableTimeout } from '@athenna/test' - -@Test() -@DisableTimeout() -public async test({ assert }: Context) { - assert.equal(1 + 1, 2) -} -``` - -#### `@Tags()` - -Add tags to a test. Tags can be used to filter tests -when running `test` command: - -```typescript -import { Test, Tags } from '@athenna/test' - -@Test() -@Tags(['run']) -public async willRun({ assert }: Context) { - assert.equal(1 + 1, 2) -} - -@Test() -@Tags(['run']) -public async willRunAlso({ assert }: Context) { - assert.equal(1 + 1, 2) -} - -@Test() -@Tags(['dont-run']) -public async willNotRun({ assert }: Context) { - assert.equal(1 + 1, 2) -} -``` - -Now you can select which tests to run by the tags: - -```shell -node artisan test --tags="run" -``` - -#### `@Cleanup()` - -Create a cleanup function with the purpose to -clean the state created by your test. This function -will always be executed, even if your test fails: - -```typescript -import { Test, Cleanup } from '@athenna/test' - -@Test() -@Cleanup(() => Config.set('app.name', 'Athenna')) -public async test({ assert }: Context) { - Config.set('app.name', 'MyApp') - - assert.equal(Config.get('app.name'), 'MyApp') -} -``` - -#### `@Setup()` - -`@Setup()` annotation works like `@BeforeEach()` -but for a specific test. The function you define -inside will be called before the test starts running: - -```typescript -import { Test, Setup } from '@athenna/test' - -@Test() -@Setup(() => Config.set('app.name', 'MyApp')) -public async test({ assert }: Context) { - assert.equal(Config.get('app.name'), 'MyApp') -} -``` - -#### `@Teardown()` - -`@Teardown()` annotation works like `@AfterEach()` -but for a specific test. The function you define -inside will be called after the test finish running: - -```typescript -import { Test, Setup, Teardown } from '@athenna/test' - -@Test() -@Setup(() => Config.set('app.name', 'MyApp')) -@Teardown(() => Config.set('app.name', 'Athenna')) -public async test({ assert }: Context) { - assert.equal(Config.get('app.name'), 'MyApp') -} -``` +your test is going to behave. You can check all of them and +it options in the [testing annotations documentation section](/docs/testing/annotations) ## Reporting test coverage diff --git a/docs/testing/mocking.mdx b/docs/testing/mocking.mdx index 8bae4ba9..22e3c08a 100644 --- a/docs/testing/mocking.mdx +++ b/docs/testing/mocking.mdx @@ -1,6 +1,6 @@ --- title: Mocking -sidebar_position: 4 +sidebar_position: 5 description: Understand how to mock dependencies and functions in Athenna. ---