setup stores and vitest
This commit is contained in:
26
README.md
26
README.md
@@ -109,7 +109,7 @@ Discord [here](https://discord.gg/3hWPDTA4kD)
|
|||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- [x] Manual test scenario for auth and sub workflows passing
|
- [x] Manual test scenario for auth and sub workflows passing
|
||||||
- [ ] Unit tests for server functions
|
- [x] Unit test framework (vitest)
|
||||||
- [ ] Integration tests for auth and sub workflows
|
- [ ] Integration tests for auth and sub workflows
|
||||||
|
|
||||||
## Special Mention
|
## Special Mention
|
||||||
@@ -120,8 +120,22 @@ This https://blog.checklyhq.com/building-a-multi-tenant-saas-data-model/ Article
|
|||||||
|
|
||||||
The focus is on separation of concerns and avoiding vendor lock in.
|
The focus is on separation of concerns and avoiding vendor lock in.
|
||||||
|
|
||||||
|
### Diagram
|
||||||
|
|
||||||
<img src="assets/images/technical_architecture.png">
|
<img src="assets/images/technical_architecture.png">
|
||||||
|
|
||||||
|
### Walkthrough
|
||||||
|
|
||||||
|
[<img src="https://img.youtube.com/vi/AFfbGuJYRqI/hqdefault.jpg">](https://www.youtube.com/watch?v=AFfbGuJYRqI)
|
||||||
|
|
||||||
|
### Tricky Decisions
|
||||||
|
|
||||||
|
_Composition over options API_ - I have decided to use composition api and setup functions accross the board including components, pages and Pinia stores. I was resistant at first, especially with the stores as I was used to Vuex but have come to the conclusion that it is easier to go one approach all over. It's also the latest and greatest and folks don't like to use a starter that starts behind the cutting edge.
|
||||||
|
|
||||||
|
_Prisma over Supabase API_ - I went with Prisma for direct DB access rather than use the Supabase client. This is Primarily to avoid lock-in with Supabase too much. Supabase is great but I thought burdening my users with a future situation where it's difficult to move off it wouldn't be very cool. Also, I really like how Prisma handles schema changes and updates to the client layer and types with just two bash commands, after using other approaches, I find this super smooth.
|
||||||
|
|
||||||
|
_Trpc over REST_ - Primarily for full thickness types without duplication on the client. Also I think the remote procedure call paradigm works well. Note however that I still include a [REST endpoint example](/server/api/note.ts) for flexibility. My preference for mobile is Flutter and there is not a Trpc client for Flutter that i'm aware off so it was important for me to make sure REST works also.
|
||||||
|
|
||||||
## Externals Setup
|
## Externals Setup
|
||||||
|
|
||||||
Things you gotta do that aren't code (and are therefore not very interesting)
|
Things you gotta do that aren't code (and are therefore not very interesting)
|
||||||
@@ -224,12 +238,22 @@ Your webhook signing secret is whsec_xxxxxxxxxxxxx (^C to quit)
|
|||||||
|
|
||||||
take ths signing secret and update the STRIPE_ENDPOINT_SECRET value in .env
|
take ths signing secret and update the STRIPE_ENDPOINT_SECRET value in .env
|
||||||
|
|
||||||
|
### Start the Server
|
||||||
|
|
||||||
Start the development server on http://localhost:3000
|
Start the development server on http://localhost:3000
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Running the Tests
|
||||||
|
|
||||||
|
There are a few unit tests, just for the stores because I needed to refactor. Feel free to extend the tests for your use cases, or not, it's your SaaS, not mine.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
Build the application for production:
|
Build the application for production:
|
||||||
|
|||||||
1041
package-lock.json
generated
1041
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,11 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "prisma generate && nuxt prepare"
|
"postinstall": "prisma generate && nuxt prepare",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nuxt/test-utils": "^3.8.1",
|
||||||
"@nuxtjs/supabase": "^1.1.3",
|
"@nuxtjs/supabase": "^1.1.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.6.6",
|
"@nuxtjs/tailwindcss": "^6.6.6",
|
||||||
"@prisma/client": "^5.2.0",
|
"@prisma/client": "^5.2.0",
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
"nuxt-icon": "^0.3.3",
|
"nuxt-icon": "^0.3.3",
|
||||||
"prisma": "^5.2.0",
|
"prisma": "^5.2.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.0.3"
|
"typescript": "^5.0.3",
|
||||||
|
"vitest": "^0.33.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.4.6",
|
"@pinia/nuxt": "^0.4.6",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
import { FullDBUser, MembershipWithUser } from '~~/lib/services/service.types';
|
import { FullDBUser, MembershipWithUser } from '~~/lib/services/service.types';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -22,167 +23,183 @@ so that other routers can use them to filter results to the active user and acco
|
|||||||
| | |
|
| | |
|
||||||
account account acccount*
|
account account acccount*
|
||||||
*/
|
*/
|
||||||
interface State {
|
export const useAccountStore = defineStore('account', () => {
|
||||||
dbUser: FullDBUser | null;
|
const dbUser = ref<FullDBUser | null>(null);
|
||||||
activeAccountId: number | null;
|
const activeAccountId = ref<number | null>(null);
|
||||||
activeAccountMembers: MembershipWithUser[];
|
const activeAccountMembers = ref<MembershipWithUser[]>([]);
|
||||||
}
|
const activeMembership = computed(() =>
|
||||||
|
dbUser?.value?.memberships.find(m => m.account_id === activeAccountId.value)
|
||||||
|
);
|
||||||
|
|
||||||
export const useAccountStore = defineStore('account', {
|
const init = async () => {
|
||||||
state: (): State => {
|
const { $client } = useNuxtApp();
|
||||||
return {
|
if (!dbUser.value) {
|
||||||
dbUser: null,
|
const { dbUser: _dbUser } = await $client.auth.getDBUser.query();
|
||||||
activeAccountId: null,
|
if (_dbUser) {
|
||||||
activeAccountMembers: []
|
dbUser.value = _dbUser;
|
||||||
};
|
|
||||||
},
|
|
||||||
getters: {
|
|
||||||
activeMembership: state =>
|
|
||||||
state.dbUser?.memberships.find(
|
|
||||||
m => m.account_id === state.activeAccountId
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
async init() {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
if (!this.dbUser) {
|
|
||||||
const { dbUser } = await $client.auth.getDBUser.query();
|
|
||||||
if (dbUser) {
|
|
||||||
this.dbUser = dbUser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!this.activeAccountId) {
|
|
||||||
const { activeAccountId } =
|
|
||||||
await $client.account.getActiveAccountId.query();
|
|
||||||
if (activeAccountId) {
|
|
||||||
this.activeAccountId = activeAccountId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
signout() {
|
|
||||||
this.dbUser = null;
|
|
||||||
this.activeAccountId = null;
|
|
||||||
this.activeAccountMembers = [];
|
|
||||||
},
|
|
||||||
async getActiveAccountMembers() {
|
|
||||||
if (
|
|
||||||
this.activeMembership &&
|
|
||||||
(this.activeMembership.access === ACCOUNT_ACCESS.ADMIN ||
|
|
||||||
this.activeMembership.access === ACCOUNT_ACCESS.OWNER)
|
|
||||||
) {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { data: memberships } =
|
|
||||||
await $client.account.getAccountMembers.useQuery();
|
|
||||||
if (memberships.value?.memberships) {
|
|
||||||
this.activeAccountMembers = memberships.value?.memberships;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async changeActiveAccount(account_id: number) {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
await $client.account.changeActiveAccount.mutate({ account_id }); // sets active account on context for other routers and sets the preference in a cookie
|
|
||||||
|
|
||||||
this.activeAccountId = account_id; // because this is used as a trigger to some other components, NEEDS TO BE AFTER THE MUTATE CALL
|
|
||||||
await this.getActiveAccountMembers(); // these relate to the active account and need to ber re-fetched
|
|
||||||
},
|
|
||||||
async changeAccountName(new_name: string) {
|
|
||||||
if (!this.activeMembership) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { account } = await $client.account.changeAccountName.mutate({
|
|
||||||
new_name
|
|
||||||
});
|
|
||||||
if (account) {
|
|
||||||
this.activeMembership.account.name = account.name;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async acceptPendingMembership(membership_id: number) {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { data: membership } =
|
|
||||||
await $client.account.acceptPendingMembership.useQuery({
|
|
||||||
membership_id
|
|
||||||
});
|
|
||||||
|
|
||||||
if (membership.value && membership.value.membership?.pending === false) {
|
|
||||||
for (const m of this.activeAccountMembers) {
|
|
||||||
if (m.id === membership_id) {
|
|
||||||
m.pending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async rejectPendingMembership(membership_id: number) {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { data: membership } =
|
|
||||||
await $client.account.rejectPendingMembership.useQuery({
|
|
||||||
membership_id
|
|
||||||
});
|
|
||||||
|
|
||||||
if (membership.value) {
|
|
||||||
this.activeAccountMembers = this.activeAccountMembers.filter(
|
|
||||||
m => m.id !== membership_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async deleteMembership(membership_id: number) {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { data: membership } =
|
|
||||||
await $client.account.deleteMembership.useQuery({ membership_id });
|
|
||||||
|
|
||||||
if (membership.value) {
|
|
||||||
this.activeAccountMembers = this.activeAccountMembers.filter(
|
|
||||||
m => m.id !== membership_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async rotateJoinPassword() {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { account } = await $client.account.rotateJoinPassword.mutate();
|
|
||||||
if (account && this.activeMembership) {
|
|
||||||
this.activeMembership.account = account;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async joinUserToAccountPending(account_id: number) {
|
|
||||||
if (!this.dbUser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { membership } =
|
|
||||||
await $client.account.joinUserToAccountPending.mutate({
|
|
||||||
account_id,
|
|
||||||
user_id: this.dbUser.id
|
|
||||||
});
|
|
||||||
if (membership && this.activeMembership) {
|
|
||||||
this.dbUser?.memberships.push(membership);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async changeUserAccessWithinAccount(
|
|
||||||
user_id: number,
|
|
||||||
access: ACCOUNT_ACCESS
|
|
||||||
) {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { membership } =
|
|
||||||
await $client.account.changeUserAccessWithinAccount.mutate({
|
|
||||||
user_id,
|
|
||||||
access
|
|
||||||
});
|
|
||||||
if (membership) {
|
|
||||||
for (const m of this.activeAccountMembers) {
|
|
||||||
if (m.id === membership.id) {
|
|
||||||
m.access = membership.access;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async claimOwnershipOfAccount() {
|
|
||||||
const { $client } = useNuxtApp();
|
|
||||||
const { memberships } =
|
|
||||||
await $client.account.claimOwnershipOfAccount.mutate();
|
|
||||||
if (memberships) {
|
|
||||||
this.activeAccountMembers = memberships;
|
|
||||||
this.activeMembership!.access = ACCOUNT_ACCESS.OWNER;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!activeAccountId.value) {
|
||||||
|
const { activeAccountId: _activeAccountId } =
|
||||||
|
await $client.account.getActiveAccountId.query();
|
||||||
|
if (_activeAccountId) {
|
||||||
|
activeAccountId.value = _activeAccountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signout = () => {
|
||||||
|
dbUser.value = null;
|
||||||
|
activeAccountId.value = null;
|
||||||
|
activeAccountMembers.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveAccountMembers = async () => {
|
||||||
|
if (
|
||||||
|
activeMembership.value &&
|
||||||
|
(activeMembership.value.access === ACCOUNT_ACCESS.ADMIN ||
|
||||||
|
activeMembership.value.access === ACCOUNT_ACCESS.OWNER)
|
||||||
|
) {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { data: memberships } =
|
||||||
|
await $client.account.getAccountMembers.useQuery();
|
||||||
|
if (memberships.value?.memberships) {
|
||||||
|
activeAccountMembers.value = memberships.value?.memberships;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeActiveAccount = async (account_id: number) => {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
await $client.account.changeActiveAccount.mutate({ account_id }); // sets active account on context for other routers and sets the preference in a cookie
|
||||||
|
|
||||||
|
activeAccountId.value = account_id; // because this is used as a trigger to some other components, NEEDS TO BE AFTER THE MUTATE CALL
|
||||||
|
await getActiveAccountMembers(); // these relate to the active account and need to ber re-fetched
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeAccountName = async (new_name: string) => {
|
||||||
|
if (!activeMembership.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { account } = await $client.account.changeAccountName.mutate({
|
||||||
|
new_name
|
||||||
|
});
|
||||||
|
if (account) {
|
||||||
|
activeMembership.value.account.name = account.name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptPendingMembership = async (membership_id: number) => {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { data: membership } =
|
||||||
|
await $client.account.acceptPendingMembership.useQuery({
|
||||||
|
membership_id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (membership.value && membership.value.membership?.pending === false) {
|
||||||
|
for (const m of activeAccountMembers.value) {
|
||||||
|
if (m.id === membership_id) {
|
||||||
|
m.pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectPendingMembership = async (membership_id: number) => {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { data: membership } =
|
||||||
|
await $client.account.rejectPendingMembership.useQuery({
|
||||||
|
membership_id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (membership.value) {
|
||||||
|
activeAccountMembers.value = activeAccountMembers.value.filter(
|
||||||
|
m => m.id !== membership_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMembership = async (membership_id: number) => {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { data: membership } =
|
||||||
|
await $client.account.deleteMembership.useQuery({ membership_id });
|
||||||
|
|
||||||
|
if (membership.value) {
|
||||||
|
activeAccountMembers.value = activeAccountMembers.value.filter(
|
||||||
|
m => m.id !== membership_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateJoinPassword = async () => {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { account } = await $client.account.rotateJoinPassword.mutate();
|
||||||
|
if (account && activeMembership.value) {
|
||||||
|
activeMembership.value.account = account;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinUserToAccountPending = async (account_id: number) => {
|
||||||
|
if (!dbUser.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { membership } =
|
||||||
|
await $client.account.joinUserToAccountPending.mutate({
|
||||||
|
account_id,
|
||||||
|
user_id: dbUser.value.id
|
||||||
|
});
|
||||||
|
if (membership && activeMembership.value) {
|
||||||
|
dbUser?.value?.memberships.push(membership);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeUserAccessWithinAccount = async (
|
||||||
|
user_id: number,
|
||||||
|
access: ACCOUNT_ACCESS
|
||||||
|
) => {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { membership } =
|
||||||
|
await $client.account.changeUserAccessWithinAccount.mutate({
|
||||||
|
user_id,
|
||||||
|
access
|
||||||
|
});
|
||||||
|
if (membership) {
|
||||||
|
for (const m of activeAccountMembers.value) {
|
||||||
|
if (m.id === membership.id) {
|
||||||
|
m.access = membership.access;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimOwnershipOfAccount = async () => {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
const { memberships } =
|
||||||
|
await $client.account.claimOwnershipOfAccount.mutate();
|
||||||
|
if (memberships) {
|
||||||
|
activeAccountMembers.value = memberships;
|
||||||
|
activeMembership.value!.access = ACCOUNT_ACCESS.OWNER;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dbUser,
|
||||||
|
activeAccountId,
|
||||||
|
activeAccountMembers,
|
||||||
|
activeMembership,
|
||||||
|
init,
|
||||||
|
signout,
|
||||||
|
getActiveAccountMembers,
|
||||||
|
changeActiveAccount,
|
||||||
|
changeAccountName,
|
||||||
|
acceptPendingMembership,
|
||||||
|
rejectPendingMembership,
|
||||||
|
deleteMembership,
|
||||||
|
rotateJoinPassword,
|
||||||
|
joinUserToAccountPending,
|
||||||
|
changeUserAccessWithinAccount,
|
||||||
|
claimOwnershipOfAccount
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { Note } from '.prisma/client';
|
|||||||
import { defineStore, storeToRefs } from 'pinia';
|
import { defineStore, storeToRefs } from 'pinia';
|
||||||
import { Ref } from 'vue';
|
import { Ref } from 'vue';
|
||||||
|
|
||||||
/*
|
|
||||||
Note) the Notes Store needs to be a 'Setup Store' (https://pinia.vuejs.org/core-concepts/#setup-stores)
|
|
||||||
because this enables the use of the watch on the Account Store
|
|
||||||
If the UI does not need to dynamically respond to a change in the active Account e.g. if state is always retrieved with an explicit fetch after onMounted.
|
|
||||||
then an Options store can be used.
|
|
||||||
*/
|
|
||||||
export const useNotesStore = defineStore('notes', () => {
|
export const useNotesStore = defineStore('notes', () => {
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const { activeAccountId } = storeToRefs(accountStore);
|
const { activeAccountId } = storeToRefs(accountStore);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
This store manages User and Account state including the ActiveAccount
|
This store manages User and Account state including the ActiveAccount
|
||||||
@@ -17,35 +18,33 @@ export enum NotificationType {
|
|||||||
Error
|
Error
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
export const useNotifyStore = defineStore('notify', () => {
|
||||||
notifications: Notification[];
|
const notifications = ref<Notification[]>([]);
|
||||||
notificationsArchive: Notification[];
|
const notificationsArchive = ref<Notification[]>([]);
|
||||||
}
|
|
||||||
|
|
||||||
export const useNotifyStore = defineStore('notify', {
|
const notify = (messageOrError: unknown, type: NotificationType) => {
|
||||||
state: (): State => {
|
let message: string = '';
|
||||||
return {
|
if (messageOrError instanceof Error) message = messageOrError.message;
|
||||||
notifications: [],
|
if (typeof messageOrError === 'string') message = messageOrError;
|
||||||
notificationsArchive: []
|
const notification: Notification = {
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
notifyTime: Date.now()
|
||||||
};
|
};
|
||||||
},
|
notifications.value.push(notification);
|
||||||
actions: {
|
setTimeout(removeNotification.bind(this), 5000, notification);
|
||||||
notify(messageOrError: unknown, type: NotificationType) {
|
};
|
||||||
let message: string = '';
|
|
||||||
if (messageOrError instanceof Error) message = messageOrError.message;
|
const removeNotification = (notification: Notification) => {
|
||||||
if (typeof messageOrError === 'string') message = messageOrError;
|
notifications.value = notifications.value.filter(
|
||||||
const notification: Notification = {
|
n => n.notifyTime != notification.notifyTime
|
||||||
message,
|
);
|
||||||
type,
|
};
|
||||||
notifyTime: Date.now()
|
|
||||||
};
|
return {
|
||||||
this.notifications.push(notification);
|
notifications,
|
||||||
setTimeout(this.removeNotification.bind(this), 5000, notification);
|
notificationsArchive,
|
||||||
},
|
notify,
|
||||||
removeNotification(notification: Notification) {
|
removeNotification
|
||||||
this.notifications = this.notifications.filter(
|
};
|
||||||
n => n.notifyTime != notification.notifyTime
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
105
test/account.store.spec.ts
Normal file
105
test/account.store.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, afterEach, beforeEach, it, vi } from 'vitest';
|
||||||
|
import { useAccountStore } from '../stores/account.store';
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
|
||||||
|
import { FullDBUser } from '~/lib/services/service.types';
|
||||||
|
|
||||||
|
const fakeInitAccountStoreAdmin = (accountStore: any) => {
|
||||||
|
const dbUser: FullDBUser = {
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
memberships: [
|
||||||
|
{ account_id: 1, access: 'ADMIN' },
|
||||||
|
{ account_id: 2, access: 'READ_ONLY' }
|
||||||
|
]
|
||||||
|
} as any;
|
||||||
|
accountStore.dbUser = dbUser;
|
||||||
|
accountStore.activeAccountId = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Account Store', async () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize the store', async () => {
|
||||||
|
// stub the useNuxtApp function with a mock client
|
||||||
|
vi.stubGlobal('useNuxtApp', () => ({
|
||||||
|
$client: {
|
||||||
|
auth: {
|
||||||
|
getDBUser: {
|
||||||
|
query: () => ({
|
||||||
|
dbUser: {
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
memberships: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
getActiveAccountId: {
|
||||||
|
query: () => ({ activeAccountId: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
|
||||||
|
// method under test
|
||||||
|
await accountStore.init();
|
||||||
|
|
||||||
|
expect(accountStore.dbUser).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
memberships: []
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(accountStore.activeAccountId).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get active account members', async () => {
|
||||||
|
// stub the useNuxtApp function with a mock client
|
||||||
|
vi.stubGlobal('useNuxtApp', () => ({
|
||||||
|
$client: {
|
||||||
|
account: {
|
||||||
|
getAccountMembers: {
|
||||||
|
useQuery: () => ({
|
||||||
|
data: { value: { memberships: [new Object() as any] } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
fakeInitAccountStoreAdmin(accountStore);
|
||||||
|
|
||||||
|
// method under test
|
||||||
|
await accountStore.getActiveAccountMembers();
|
||||||
|
|
||||||
|
expect(accountStore.activeAccountMembers.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get an active membership', async () => {
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
fakeInitAccountStoreAdmin(accountStore);
|
||||||
|
|
||||||
|
expect(accountStore.activeMembership).toEqual({
|
||||||
|
account_id: 1,
|
||||||
|
access: 'ADMIN'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should signout', async () => {
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
fakeInitAccountStoreAdmin(accountStore);
|
||||||
|
|
||||||
|
await accountStore.signout();
|
||||||
|
|
||||||
|
expect(accountStore.dbUser).toBeNull();
|
||||||
|
expect(accountStore.activeAccountId).toBeNull();
|
||||||
|
expect(accountStore.activeAccountMembers.length).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
test/notify.store.spec.ts
Normal file
54
test/notify.store.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from 'vitest';
|
||||||
|
import { setup, $fetch } from '@nuxt/test-utils';
|
||||||
|
import { useNotifyStore, NotificationType } from '../stores/notify.store';
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
|
||||||
|
describe('Notify Store', async () => {
|
||||||
|
await setup({
|
||||||
|
// test context options
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// creates a fresh pinia and makes it active
|
||||||
|
// so it's automatically picked up by any useStore() call
|
||||||
|
// without having to pass it to it: `useStore(pinia)`
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should add a notification', () => {
|
||||||
|
const notifyStore = useNotifyStore();
|
||||||
|
const message = 'Test notification';
|
||||||
|
const type = NotificationType.Info;
|
||||||
|
|
||||||
|
notifyStore.notify(message, type);
|
||||||
|
|
||||||
|
expect(notifyStore.notifications).toHaveLength(1);
|
||||||
|
expect(notifyStore.notifications[0].message).toBe(message);
|
||||||
|
expect(notifyStore.notifications[0].type).toBe(type);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should add an Error notification', () => {
|
||||||
|
const notifyStore = useNotifyStore();
|
||||||
|
const error = new Error('Test error');
|
||||||
|
const type = NotificationType.Error;
|
||||||
|
|
||||||
|
notifyStore.notify(error, type);
|
||||||
|
|
||||||
|
expect(notifyStore.notifications).toHaveLength(1);
|
||||||
|
expect(notifyStore.notifications[0].message).toBe(error.message);
|
||||||
|
expect(notifyStore.notifications[0].type).toBe(type);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove a notification', () => {
|
||||||
|
const notifyStore = useNotifyStore();
|
||||||
|
const message = 'Test notification';
|
||||||
|
const type = NotificationType.Info;
|
||||||
|
|
||||||
|
notifyStore.notify(message, type);
|
||||||
|
const notification = notifyStore.notifications[0];
|
||||||
|
|
||||||
|
notifyStore.removeNotification(notification);
|
||||||
|
|
||||||
|
expect(notifyStore.notifications).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// ... Specify options here.
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~~': fileURLToPath(new URL('./', import.meta.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user