@onebun/core 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/application/application.test.ts +36 -0
- package/src/application/application.ts +258 -6
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +1 -1
- package/src/decorators/decorators.test.ts +202 -12
- package/src/decorators/decorators.ts +228 -7
- package/src/docs-examples.test.ts +1339 -254
- package/src/file/index.ts +8 -0
- package/src/file/onebun-file.test.ts +315 -0
- package/src/file/onebun-file.ts +304 -0
- package/src/index.ts +20 -0
- package/src/module/controller.ts +96 -10
- package/src/module/index.ts +2 -1
- package/src/module/lifecycle.ts +13 -0
- package/src/module/middleware.ts +76 -0
- package/src/module/module.test.ts +138 -1
- package/src/module/module.ts +127 -2
- package/src/types.ts +169 -0
|
@@ -38,9 +38,95 @@ import type {
|
|
|
38
38
|
SseGenerator,
|
|
39
39
|
OneBunRequest,
|
|
40
40
|
OneBunResponse,
|
|
41
|
+
MiddlewareClass,
|
|
42
|
+
OnModuleConfigure,
|
|
41
43
|
} from './types';
|
|
42
44
|
import type { ServerWebSocket } from 'bun';
|
|
43
45
|
|
|
46
|
+
import {
|
|
47
|
+
Controller,
|
|
48
|
+
Get,
|
|
49
|
+
Post,
|
|
50
|
+
Put,
|
|
51
|
+
Delete,
|
|
52
|
+
Patch,
|
|
53
|
+
Param,
|
|
54
|
+
Query,
|
|
55
|
+
Body,
|
|
56
|
+
Header,
|
|
57
|
+
Req,
|
|
58
|
+
Cookie,
|
|
59
|
+
Module,
|
|
60
|
+
Service,
|
|
61
|
+
BaseService,
|
|
62
|
+
BaseController,
|
|
63
|
+
UseMiddleware,
|
|
64
|
+
getControllerMiddleware,
|
|
65
|
+
getServiceTag,
|
|
66
|
+
getControllerMetadata,
|
|
67
|
+
HttpStatusCode,
|
|
68
|
+
ParamType,
|
|
69
|
+
NotFoundError,
|
|
70
|
+
InternalServerError,
|
|
71
|
+
OneBunBaseError,
|
|
72
|
+
Env,
|
|
73
|
+
validate,
|
|
74
|
+
validateOrThrow,
|
|
75
|
+
MultiServiceApplication,
|
|
76
|
+
OneBunApplication,
|
|
77
|
+
createServiceDefinition,
|
|
78
|
+
createServiceClient,
|
|
79
|
+
WebSocketGateway,
|
|
80
|
+
BaseWebSocketGateway,
|
|
81
|
+
OnConnect,
|
|
82
|
+
OnDisconnect,
|
|
83
|
+
OnJoinRoom,
|
|
84
|
+
OnLeaveRoom,
|
|
85
|
+
OnMessage,
|
|
86
|
+
Client,
|
|
87
|
+
Socket,
|
|
88
|
+
MessageData,
|
|
89
|
+
RoomName,
|
|
90
|
+
PatternParams,
|
|
91
|
+
WsServer,
|
|
92
|
+
UseWsGuards,
|
|
93
|
+
WsAuthGuard,
|
|
94
|
+
WsPermissionGuard,
|
|
95
|
+
WsAnyPermissionGuard,
|
|
96
|
+
createGuard,
|
|
97
|
+
createInMemoryWsStorage,
|
|
98
|
+
SharedRedisProvider,
|
|
99
|
+
Sse,
|
|
100
|
+
getSseMetadata,
|
|
101
|
+
formatSseEvent,
|
|
102
|
+
createSseStream,
|
|
103
|
+
createWsServiceDefinition,
|
|
104
|
+
createWsClient,
|
|
105
|
+
createNativeWsClient,
|
|
106
|
+
matchPattern,
|
|
107
|
+
makeMockLoggerLayer,
|
|
108
|
+
hasOnModuleInit,
|
|
109
|
+
hasOnApplicationInit,
|
|
110
|
+
hasOnModuleDestroy,
|
|
111
|
+
hasBeforeApplicationDestroy,
|
|
112
|
+
hasOnApplicationDestroy,
|
|
113
|
+
callOnModuleInit,
|
|
114
|
+
callOnApplicationInit,
|
|
115
|
+
callOnModuleDestroy,
|
|
116
|
+
callBeforeApplicationDestroy,
|
|
117
|
+
callOnApplicationDestroy,
|
|
118
|
+
UploadedFile,
|
|
119
|
+
UploadedFiles,
|
|
120
|
+
FormField,
|
|
121
|
+
OneBunFile,
|
|
122
|
+
MimeType,
|
|
123
|
+
matchMimeType,
|
|
124
|
+
BaseMiddleware,
|
|
125
|
+
DEFAULT_IDLE_TIMEOUT,
|
|
126
|
+
DEFAULT_SSE_HEARTBEAT_MS,
|
|
127
|
+
DEFAULT_SSE_TIMEOUT,
|
|
128
|
+
} from './';
|
|
129
|
+
|
|
44
130
|
|
|
45
131
|
/**
|
|
46
132
|
* @source docs/index.md#minimal-working-example
|
|
@@ -241,8 +327,8 @@ describe('Core README Examples', () => {
|
|
|
241
327
|
it('should use middleware decorator', () => {
|
|
242
328
|
// From README: Middleware example
|
|
243
329
|
function loggerMiddleware(
|
|
244
|
-
_req:
|
|
245
|
-
next: () => Promise<
|
|
330
|
+
_req: OneBunRequest,
|
|
331
|
+
next: () => Promise<OneBunResponse>,
|
|
246
332
|
): Promise<Response> {
|
|
247
333
|
// eslint-disable-next-line no-console
|
|
248
334
|
console.log('Request received');
|
|
@@ -251,8 +337,8 @@ describe('Core README Examples', () => {
|
|
|
251
337
|
}
|
|
252
338
|
|
|
253
339
|
function authMiddleware(
|
|
254
|
-
req:
|
|
255
|
-
next: () => Promise<
|
|
340
|
+
req: OneBunRequest,
|
|
341
|
+
next: () => Promise<OneBunResponse>,
|
|
256
342
|
): Promise<Response> {
|
|
257
343
|
const token = req.headers.get('Authorization');
|
|
258
344
|
if (!token) {
|
|
@@ -485,8 +571,8 @@ describe('Decorators API Documentation Examples', () => {
|
|
|
485
571
|
it('should apply middleware to route handler', () => {
|
|
486
572
|
// From docs: @UseMiddleware() example
|
|
487
573
|
const authMiddleware = async (
|
|
488
|
-
req:
|
|
489
|
-
next: () => Promise<
|
|
574
|
+
req: OneBunRequest,
|
|
575
|
+
next: () => Promise<OneBunResponse>,
|
|
490
576
|
) => {
|
|
491
577
|
const token = req.headers.get('Authorization');
|
|
492
578
|
if (!token) {
|
|
@@ -497,8 +583,8 @@ describe('Decorators API Documentation Examples', () => {
|
|
|
497
583
|
};
|
|
498
584
|
|
|
499
585
|
const logMiddleware = async (
|
|
500
|
-
_req:
|
|
501
|
-
next: () => Promise<
|
|
586
|
+
_req: OneBunRequest,
|
|
587
|
+
next: () => Promise<OneBunResponse>,
|
|
502
588
|
) => {
|
|
503
589
|
// eslint-disable-next-line no-console
|
|
504
590
|
console.log('Request logged');
|
|
@@ -523,6 +609,60 @@ describe('Decorators API Documentation Examples', () => {
|
|
|
523
609
|
|
|
524
610
|
expect(UserController).toBeDefined();
|
|
525
611
|
});
|
|
612
|
+
|
|
613
|
+
it('should apply middleware to all routes when used as class decorator (docs/api/decorators.md)', () => {
|
|
614
|
+
// From docs: @UseMiddleware() class-level example
|
|
615
|
+
const authMiddleware = async (
|
|
616
|
+
req: OneBunRequest,
|
|
617
|
+
next: () => Promise<OneBunResponse>,
|
|
618
|
+
) => {
|
|
619
|
+
const token = req.headers.get('Authorization');
|
|
620
|
+
if (!token) {
|
|
621
|
+
return new Response('Unauthorized', { status: 401 });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return await next();
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const auditLogMiddleware = async (
|
|
628
|
+
_req: OneBunRequest,
|
|
629
|
+
next: () => Promise<OneBunResponse>,
|
|
630
|
+
) => await next();
|
|
631
|
+
|
|
632
|
+
@Controller('/admin')
|
|
633
|
+
@UseMiddleware(authMiddleware) // Applied to ALL routes in this controller
|
|
634
|
+
class AdminController extends BaseController {
|
|
635
|
+
@Get('/dashboard')
|
|
636
|
+
getDashboard() {
|
|
637
|
+
return this.success({ stats: {} });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
@Put('/settings')
|
|
641
|
+
@UseMiddleware(auditLogMiddleware) // Additional middleware for this route
|
|
642
|
+
updateSettings() {
|
|
643
|
+
return this.success({ updated: true });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
expect(AdminController).toBeDefined();
|
|
648
|
+
|
|
649
|
+
// Verify controller-level middleware is stored
|
|
650
|
+
const ctrlMiddleware = getControllerMiddleware(AdminController);
|
|
651
|
+
expect(ctrlMiddleware).toHaveLength(1);
|
|
652
|
+
expect(ctrlMiddleware[0]).toBe(authMiddleware);
|
|
653
|
+
|
|
654
|
+
// Verify route-level middleware is stored on the method
|
|
655
|
+
const metadata = getControllerMetadata(AdminController);
|
|
656
|
+
expect(metadata).toBeDefined();
|
|
657
|
+
|
|
658
|
+
const settingsRoute = metadata!.routes.find((r) => r.path === '/settings');
|
|
659
|
+
expect(settingsRoute?.middleware).toHaveLength(1);
|
|
660
|
+
expect(settingsRoute!.middleware![0]).toBe(auditLogMiddleware);
|
|
661
|
+
|
|
662
|
+
// The dashboard route should have no route-level middleware (only controller-level)
|
|
663
|
+
const dashboardRoute = metadata!.routes.find((r) => r.path === '/dashboard');
|
|
664
|
+
expect(dashboardRoute?.middleware?.length ?? 0).toBe(0);
|
|
665
|
+
});
|
|
526
666
|
});
|
|
527
667
|
});
|
|
528
668
|
|
|
@@ -784,175 +924,599 @@ describe('Controllers API Documentation Examples', () => {
|
|
|
784
924
|
});
|
|
785
925
|
});
|
|
786
926
|
|
|
787
|
-
describe('
|
|
788
|
-
describe('
|
|
789
|
-
it('should
|
|
790
|
-
// From docs:
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
this.count++;
|
|
797
|
-
this.logger.debug('Counter incremented', { count: this.count });
|
|
927
|
+
describe('Middleware API Documentation Examples (docs/api/controllers.md)', () => {
|
|
928
|
+
describe('BaseMiddleware (docs/api/controllers.md#basemiddleware)', () => {
|
|
929
|
+
it('should define a middleware class extending BaseMiddleware', () => {
|
|
930
|
+
// From docs: BaseMiddleware example
|
|
931
|
+
class RequestLogMiddleware extends BaseMiddleware {
|
|
932
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
933
|
+
this.logger.info(`${req.method} ${new URL(req.url).pathname}`);
|
|
934
|
+
const response = await next();
|
|
935
|
+
response.headers.set('X-Request-Duration', String(Date.now()));
|
|
798
936
|
|
|
799
|
-
return
|
|
937
|
+
return response;
|
|
800
938
|
}
|
|
939
|
+
}
|
|
801
940
|
|
|
802
|
-
|
|
803
|
-
|
|
941
|
+
expect(RequestLogMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
|
|
942
|
+
// eslint-disable-next-line jest/unbound-method
|
|
943
|
+
expect(RequestLogMiddleware.prototype.use).toBeInstanceOf(Function);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
804
946
|
|
|
805
|
-
|
|
806
|
-
|
|
947
|
+
describe('Route-Level Middleware (docs/api/controllers.md#route-level-middleware)', () => {
|
|
948
|
+
it('should apply middleware to individual routes', () => {
|
|
949
|
+
// From docs: Route-Level Middleware example
|
|
950
|
+
class AuthMiddleware extends BaseMiddleware {
|
|
951
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
952
|
+
const token = req.headers.get('Authorization');
|
|
953
|
+
if (!token) {
|
|
954
|
+
return new Response('Unauthorized', { status: 401 });
|
|
955
|
+
}
|
|
807
956
|
|
|
808
|
-
|
|
809
|
-
return this.count;
|
|
957
|
+
return await next();
|
|
810
958
|
}
|
|
811
959
|
}
|
|
812
960
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
class UserRepository extends BaseService {}
|
|
961
|
+
@Controller('/api')
|
|
962
|
+
class ApiController extends BaseController {
|
|
963
|
+
@Get('/public')
|
|
964
|
+
publicEndpoint() {
|
|
965
|
+
return { message: 'Anyone can see this' };
|
|
966
|
+
}
|
|
820
967
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
constructor(private repository: UserRepository) {
|
|
826
|
-
super();
|
|
968
|
+
@Post('/protected')
|
|
969
|
+
@UseMiddleware(AuthMiddleware)
|
|
970
|
+
protectedEndpoint() {
|
|
971
|
+
return { message: 'Auth required' };
|
|
827
972
|
}
|
|
828
973
|
}
|
|
829
974
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
});
|
|
975
|
+
const metadata = getControllerMetadata(ApiController);
|
|
976
|
+
expect(metadata).toBeDefined();
|
|
833
977
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
* @source docs/api/services.md#service-tags-advanced
|
|
837
|
-
*/
|
|
838
|
-
it('should get service tag from class', () => {
|
|
839
|
-
@Service()
|
|
840
|
-
class MyService extends BaseService {}
|
|
978
|
+
const publicRoute = metadata!.routes.find((r) => r.path === '/public');
|
|
979
|
+
expect(publicRoute?.middleware?.length ?? 0).toBe(0);
|
|
841
980
|
|
|
842
|
-
const
|
|
843
|
-
expect(
|
|
981
|
+
const protectedRoute = metadata!.routes.find((r) => r.path === '/protected');
|
|
982
|
+
expect(protectedRoute?.middleware).toHaveLength(1);
|
|
983
|
+
expect(protectedRoute!.middleware![0]).toBe(AuthMiddleware);
|
|
844
984
|
});
|
|
845
985
|
});
|
|
846
986
|
|
|
847
|
-
describe('
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
});
|
|
987
|
+
describe('Controller-Level Middleware (docs/api/controllers.md#controller-level-middleware)', () => {
|
|
988
|
+
it('should apply middleware to all routes via class decorator', () => {
|
|
989
|
+
// From docs: Controller-Level Middleware example
|
|
990
|
+
class AuthMiddleware extends BaseMiddleware {
|
|
991
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
992
|
+
const token = req.headers.get('Authorization');
|
|
993
|
+
if (!token) {
|
|
994
|
+
return new Response('Unauthorized', { status: 401 });
|
|
995
|
+
}
|
|
857
996
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
it('should have formatError method', () => {
|
|
862
|
-
// From docs: BaseService has formatError method
|
|
863
|
-
// Note: Cannot instantiate service without OneBunApplication context
|
|
864
|
-
// Check prototype instead
|
|
865
|
-
expect(typeof BaseService.prototype['formatError']).toBe('function');
|
|
866
|
-
});
|
|
867
|
-
});
|
|
997
|
+
return await next();
|
|
998
|
+
}
|
|
999
|
+
}
|
|
868
1000
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
this.logger.info('General information'); // Level 2
|
|
881
|
-
this.logger.warn('Warning message'); // Level 3
|
|
882
|
-
this.logger.error('Error occurred'); // Level 4
|
|
883
|
-
this.logger.fatal('Fatal error'); // Level 5
|
|
1001
|
+
@Controller('/admin')
|
|
1002
|
+
@UseMiddleware(AuthMiddleware)
|
|
1003
|
+
class AdminController extends BaseController {
|
|
1004
|
+
@Get('/dashboard')
|
|
1005
|
+
getDashboard() {
|
|
1006
|
+
return { stats: { users: 100 } };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
@Put('/settings')
|
|
1010
|
+
updateSettings() {
|
|
1011
|
+
return { updated: true };
|
|
884
1012
|
}
|
|
885
1013
|
}
|
|
886
1014
|
|
|
887
|
-
|
|
1015
|
+
// Controller-level middleware is stored separately
|
|
1016
|
+
const ctrlMiddleware = getControllerMiddleware(AdminController);
|
|
1017
|
+
expect(ctrlMiddleware).toHaveLength(1);
|
|
1018
|
+
expect(ctrlMiddleware[0]).toBe(AuthMiddleware);
|
|
888
1019
|
});
|
|
889
|
-
});
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', () => {
|
|
893
|
-
describe('OnModuleInit Interface', () => {
|
|
894
|
-
/**
|
|
895
|
-
* @source docs/api/services.md#lifecycle-hooks
|
|
896
|
-
*/
|
|
897
|
-
it('should implement OnModuleInit interface', () => {
|
|
898
|
-
// From docs: OnModuleInit example
|
|
899
|
-
@Service()
|
|
900
|
-
class DatabaseService extends BaseService implements OnModuleInit {
|
|
901
|
-
private connection: unknown = null;
|
|
902
1020
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1021
|
+
it('should combine controller-level and route-level middleware', () => {
|
|
1022
|
+
// From docs: Combined controller + route middleware example
|
|
1023
|
+
class AuthMiddleware extends BaseMiddleware {
|
|
1024
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1025
|
+
return await next();
|
|
907
1026
|
}
|
|
1027
|
+
}
|
|
908
1028
|
|
|
909
|
-
|
|
910
|
-
|
|
1029
|
+
class AuditLogMiddleware extends BaseMiddleware {
|
|
1030
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1031
|
+
return await next();
|
|
911
1032
|
}
|
|
912
1033
|
}
|
|
913
1034
|
|
|
914
|
-
|
|
915
|
-
//
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1035
|
+
@Controller('/admin')
|
|
1036
|
+
@UseMiddleware(AuthMiddleware) // Runs first on all routes
|
|
1037
|
+
class AdminController extends BaseController {
|
|
1038
|
+
@Get('/dashboard')
|
|
1039
|
+
getDashboard() {
|
|
1040
|
+
return { stats: {} };
|
|
1041
|
+
}
|
|
920
1042
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
it('should implement OnApplicationInit interface', () => {
|
|
926
|
-
// From docs: OnApplicationInit example
|
|
927
|
-
@Service()
|
|
928
|
-
class CacheService extends BaseService implements OnApplicationInit {
|
|
929
|
-
async onApplicationInit(): Promise<void> {
|
|
930
|
-
// Called after all modules initialized, before HTTP server starts
|
|
931
|
-
this.logger.info('Warming up cache');
|
|
1043
|
+
@Put('/settings')
|
|
1044
|
+
@UseMiddleware(AuditLogMiddleware) // Runs second, only on this route
|
|
1045
|
+
updateSettings() {
|
|
1046
|
+
return { updated: true };
|
|
932
1047
|
}
|
|
933
1048
|
}
|
|
934
1049
|
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
expect(
|
|
1050
|
+
// Controller-level middleware
|
|
1051
|
+
const ctrlMiddleware = getControllerMiddleware(AdminController);
|
|
1052
|
+
expect(ctrlMiddleware).toHaveLength(1);
|
|
1053
|
+
expect(ctrlMiddleware[0]).toBe(AuthMiddleware);
|
|
1054
|
+
|
|
1055
|
+
// Route-level middleware only on /settings
|
|
1056
|
+
const metadata = getControllerMetadata(AdminController);
|
|
1057
|
+
const settingsRoute = metadata!.routes.find((r) => r.path === '/settings');
|
|
1058
|
+
expect(settingsRoute?.middleware).toHaveLength(1);
|
|
1059
|
+
expect(settingsRoute!.middleware![0]).toBe(AuditLogMiddleware);
|
|
1060
|
+
|
|
1061
|
+
const dashboardRoute = metadata!.routes.find((r) => r.path === '/dashboard');
|
|
1062
|
+
expect(dashboardRoute?.middleware?.length ?? 0).toBe(0);
|
|
938
1063
|
});
|
|
939
1064
|
});
|
|
940
1065
|
|
|
941
|
-
describe('
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
// Called during shutdown, after HTTP server stops
|
|
951
|
-
this.logger.info('Closing connections');
|
|
1066
|
+
describe('Application-Wide Middleware (docs/api/controllers.md#application-wide-middleware)', () => {
|
|
1067
|
+
it('should accept middleware classes in ApplicationOptions', () => {
|
|
1068
|
+
// From docs: Application-Wide Middleware example
|
|
1069
|
+
class RequestIdMiddleware extends BaseMiddleware {
|
|
1070
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1071
|
+
const response = await next();
|
|
1072
|
+
response.headers.set('X-Request-ID', crypto.randomUUID());
|
|
1073
|
+
|
|
1074
|
+
return response;
|
|
952
1075
|
}
|
|
953
1076
|
}
|
|
954
1077
|
|
|
955
|
-
|
|
1078
|
+
class CorsMiddleware extends BaseMiddleware {
|
|
1079
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1080
|
+
const response = await next();
|
|
1081
|
+
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
1082
|
+
|
|
1083
|
+
return response;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Verify that middleware option is accepted by OneBunApplication
|
|
1088
|
+
// (We don't start the app, just verify the type/constructor accepts it)
|
|
1089
|
+
@Module({
|
|
1090
|
+
controllers: [],
|
|
1091
|
+
providers: [],
|
|
1092
|
+
})
|
|
1093
|
+
class AppModule {}
|
|
1094
|
+
|
|
1095
|
+
const app = new OneBunApplication(AppModule, {
|
|
1096
|
+
port: 0,
|
|
1097
|
+
middleware: [RequestIdMiddleware, CorsMiddleware],
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
expect(app).toBeDefined();
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
describe('Middleware Execution Order (docs/api/controllers.md#middleware-execution-order)', () => {
|
|
1105
|
+
it('should support all four middleware levels together', () => {
|
|
1106
|
+
// From docs: Combining All Levels example
|
|
1107
|
+
class RequestIdMiddleware extends BaseMiddleware {
|
|
1108
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1109
|
+
return await next();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
class TimingMiddleware extends BaseMiddleware {
|
|
1114
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1115
|
+
return await next();
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
class JwtAuthMiddleware extends BaseMiddleware {
|
|
1120
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1121
|
+
return await next();
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
class JsonOnlyMiddleware extends BaseMiddleware {
|
|
1126
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1127
|
+
return await next();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
@Controller('/admin')
|
|
1132
|
+
@UseMiddleware(JwtAuthMiddleware)
|
|
1133
|
+
class AdminController extends BaseController {
|
|
1134
|
+
@Get('/stats')
|
|
1135
|
+
getStats() {
|
|
1136
|
+
return { stats: {} };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
@Post('/users')
|
|
1140
|
+
@UseMiddleware(JsonOnlyMiddleware)
|
|
1141
|
+
createUser() {
|
|
1142
|
+
return { created: true };
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Verify controller middleware
|
|
1147
|
+
const ctrlMiddleware = getControllerMiddleware(AdminController);
|
|
1148
|
+
expect(ctrlMiddleware).toHaveLength(1);
|
|
1149
|
+
expect(ctrlMiddleware[0]).toBe(JwtAuthMiddleware);
|
|
1150
|
+
|
|
1151
|
+
// Verify route-level middleware on /users only
|
|
1152
|
+
const metadata = getControllerMetadata(AdminController);
|
|
1153
|
+
const statsRoute = metadata!.routes.find((r) => r.path === '/stats');
|
|
1154
|
+
expect(statsRoute?.middleware?.length ?? 0).toBe(0);
|
|
1155
|
+
|
|
1156
|
+
const usersRoute = metadata!.routes.find((r) => r.path === '/users');
|
|
1157
|
+
expect(usersRoute?.middleware).toHaveLength(1);
|
|
1158
|
+
expect(usersRoute!.middleware![0]).toBe(JsonOnlyMiddleware);
|
|
1159
|
+
|
|
1160
|
+
// Global middleware would be set via ApplicationOptions.middleware
|
|
1161
|
+
@Module({
|
|
1162
|
+
controllers: [AdminController],
|
|
1163
|
+
providers: [],
|
|
1164
|
+
})
|
|
1165
|
+
class AppModule {}
|
|
1166
|
+
|
|
1167
|
+
const app = new OneBunApplication(AppModule, {
|
|
1168
|
+
port: 0,
|
|
1169
|
+
middleware: [RequestIdMiddleware, TimingMiddleware],
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
expect(app).toBeDefined();
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
describe('Real-World Middleware Examples (docs/api/controllers.md#real-world-examples)', () => {
|
|
1177
|
+
it('should define custom authentication middleware', () => {
|
|
1178
|
+
// From docs: Custom Authentication Middleware example
|
|
1179
|
+
class JwtAuthMiddleware extends BaseMiddleware {
|
|
1180
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1181
|
+
const authHeader = req.headers.get('Authorization');
|
|
1182
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
1183
|
+
this.logger.warn('Missing or invalid Authorization header');
|
|
1184
|
+
|
|
1185
|
+
return new Response(JSON.stringify({
|
|
1186
|
+
success: false,
|
|
1187
|
+
code: 401,
|
|
1188
|
+
message: 'Missing or invalid Authorization header',
|
|
1189
|
+
}), {
|
|
1190
|
+
status: 401,
|
|
1191
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Validate the token (your logic here)
|
|
1197
|
+
this.logger.debug(`Validating JWT token: ${authHeader.slice(7).substring(0, 8)}...`);
|
|
1198
|
+
|
|
1199
|
+
return await next();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
expect(JwtAuthMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it('should define request validation middleware', () => {
|
|
1207
|
+
// From docs: Request Validation Middleware example
|
|
1208
|
+
class JsonOnlyMiddleware extends BaseMiddleware {
|
|
1209
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1210
|
+
if (req.method !== 'GET' && req.method !== 'DELETE') {
|
|
1211
|
+
const contentType = req.headers.get('Content-Type');
|
|
1212
|
+
if (!contentType?.includes('application/json')) {
|
|
1213
|
+
this.logger.warn(`Invalid Content-Type: ${contentType}`);
|
|
1214
|
+
|
|
1215
|
+
return new Response(JSON.stringify({
|
|
1216
|
+
success: false,
|
|
1217
|
+
code: 415,
|
|
1218
|
+
message: 'Content-Type must be application/json',
|
|
1219
|
+
}), {
|
|
1220
|
+
status: 415,
|
|
1221
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1222
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return await next();
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
expect(JsonOnlyMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it('should define timing/logging middleware', () => {
|
|
1235
|
+
// From docs: Timing / Logging Middleware example
|
|
1236
|
+
class TimingMiddleware extends BaseMiddleware {
|
|
1237
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1238
|
+
const start = performance.now();
|
|
1239
|
+
const response = await next();
|
|
1240
|
+
const duration = (performance.now() - start).toFixed(2);
|
|
1241
|
+
response.headers.set('X-Response-Time', `${duration}ms`);
|
|
1242
|
+
this.logger.info(`${req.method} ${new URL(req.url).pathname} — ${duration}ms`);
|
|
1243
|
+
|
|
1244
|
+
return response;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
expect(TimingMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
describe('Module-Level Middleware (docs/api/controllers.md#module-level-middleware)', () => {
|
|
1253
|
+
it('should define module-level middleware via OnModuleConfigure interface', () => {
|
|
1254
|
+
// From docs: Module-Level Middleware example
|
|
1255
|
+
class TenantMiddleware extends BaseMiddleware {
|
|
1256
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1257
|
+
const tenantId = req.headers.get('X-Tenant-ID');
|
|
1258
|
+
if (!tenantId) {
|
|
1259
|
+
return new Response('Missing X-Tenant-ID', { status: 400 });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
return await next();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
@Controller('/users')
|
|
1267
|
+
class UserController extends BaseController {
|
|
1268
|
+
@Get('/')
|
|
1269
|
+
getUsers() {
|
|
1270
|
+
return [];
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
@Module({
|
|
1275
|
+
controllers: [UserController],
|
|
1276
|
+
})
|
|
1277
|
+
class UserModule implements OnModuleConfigure {
|
|
1278
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
1279
|
+
return [TenantMiddleware];
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Verify the module class has configureMiddleware
|
|
1284
|
+
const instance = new UserModule();
|
|
1285
|
+
const middleware = instance.configureMiddleware();
|
|
1286
|
+
expect(middleware).toHaveLength(1);
|
|
1287
|
+
expect(middleware[0]).toBe(TenantMiddleware);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('should inherit module middleware in child modules', () => {
|
|
1291
|
+
// From docs: Module middleware inheritance example
|
|
1292
|
+
class RequestIdMiddleware extends BaseMiddleware {
|
|
1293
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1294
|
+
return await next();
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
class TenantMiddleware extends BaseMiddleware {
|
|
1299
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1300
|
+
return await next();
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
@Controller('/users')
|
|
1305
|
+
class UserController extends BaseController {
|
|
1306
|
+
@Get('/')
|
|
1307
|
+
getUsers() {
|
|
1308
|
+
return [];
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
@Module({
|
|
1313
|
+
controllers: [UserController],
|
|
1314
|
+
})
|
|
1315
|
+
class UserModule implements OnModuleConfigure {
|
|
1316
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
1317
|
+
return [TenantMiddleware];
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
@Controller('/health')
|
|
1322
|
+
class HealthController extends BaseController {
|
|
1323
|
+
@Get('/')
|
|
1324
|
+
health() {
|
|
1325
|
+
return { ok: true };
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
@Module({
|
|
1330
|
+
imports: [UserModule],
|
|
1331
|
+
controllers: [HealthController],
|
|
1332
|
+
})
|
|
1333
|
+
class AppModule implements OnModuleConfigure {
|
|
1334
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
1335
|
+
return [RequestIdMiddleware];
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Verify both modules have configureMiddleware
|
|
1340
|
+
const appInstance = new AppModule();
|
|
1341
|
+
expect(appInstance.configureMiddleware()).toHaveLength(1);
|
|
1342
|
+
expect(appInstance.configureMiddleware()[0]).toBe(RequestIdMiddleware);
|
|
1343
|
+
|
|
1344
|
+
const userInstance = new UserModule();
|
|
1345
|
+
expect(userInstance.configureMiddleware()).toHaveLength(1);
|
|
1346
|
+
expect(userInstance.configureMiddleware()[0]).toBe(TenantMiddleware);
|
|
1347
|
+
});
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
describe('Services API Documentation Examples', () => {
|
|
1352
|
+
describe('BaseService (docs/api/services.md)', () => {
|
|
1353
|
+
it('should create basic service', () => {
|
|
1354
|
+
// From docs: Basic Service example
|
|
1355
|
+
@Service()
|
|
1356
|
+
class CounterService extends BaseService {
|
|
1357
|
+
private count = 0;
|
|
1358
|
+
|
|
1359
|
+
increment(): number {
|
|
1360
|
+
this.count++;
|
|
1361
|
+
this.logger.debug('Counter incremented', { count: this.count });
|
|
1362
|
+
|
|
1363
|
+
return this.count;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
decrement(): number {
|
|
1367
|
+
this.count--;
|
|
1368
|
+
|
|
1369
|
+
return this.count;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
getValue(): number {
|
|
1373
|
+
return this.count;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
expect(CounterService).toBeDefined();
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
it('should create service with dependencies', () => {
|
|
1381
|
+
// From docs: Service with Dependencies example
|
|
1382
|
+
@Service()
|
|
1383
|
+
class UserRepository extends BaseService {}
|
|
1384
|
+
|
|
1385
|
+
@Service()
|
|
1386
|
+
class UserService extends BaseService {
|
|
1387
|
+
// Dependencies are auto-injected via constructor
|
|
1388
|
+
// Logger and config are available immediately after super()
|
|
1389
|
+
constructor(private repository: UserRepository) {
|
|
1390
|
+
super();
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
expect(UserService).toBeDefined();
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
describe('getServiceTag (docs/api/services.md)', () => {
|
|
1399
|
+
/**
|
|
1400
|
+
* @source docs/api/services.md#service-tags-advanced
|
|
1401
|
+
*/
|
|
1402
|
+
it('should get service tag from class', () => {
|
|
1403
|
+
@Service()
|
|
1404
|
+
class MyService extends BaseService {}
|
|
1405
|
+
|
|
1406
|
+
const tag = getServiceTag(MyService);
|
|
1407
|
+
expect(tag).toBeDefined();
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
describe('BaseService Methods (docs/api/services.md)', () => {
|
|
1412
|
+
/**
|
|
1413
|
+
* @source docs/api/services.md#class-definition
|
|
1414
|
+
*/
|
|
1415
|
+
it('should have runEffect method', () => {
|
|
1416
|
+
// From docs: BaseService has runEffect method for Effect.js integration
|
|
1417
|
+
// Note: Cannot instantiate service without OneBunApplication context
|
|
1418
|
+
// Check prototype instead
|
|
1419
|
+
expect(typeof BaseService.prototype['runEffect']).toBe('function');
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* @source docs/api/services.md#class-definition
|
|
1424
|
+
*/
|
|
1425
|
+
it('should have formatError method', () => {
|
|
1426
|
+
// From docs: BaseService has formatError method
|
|
1427
|
+
// Note: Cannot instantiate service without OneBunApplication context
|
|
1428
|
+
// Check prototype instead
|
|
1429
|
+
expect(typeof BaseService.prototype['formatError']).toBe('function');
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
describe('Service Logger (docs/api/services.md)', () => {
|
|
1434
|
+
/**
|
|
1435
|
+
* @source docs/api/services.md#log-levels
|
|
1436
|
+
*/
|
|
1437
|
+
it('should support all log levels', () => {
|
|
1438
|
+
@Service()
|
|
1439
|
+
class EmailService extends BaseService {
|
|
1440
|
+
async send() {
|
|
1441
|
+
// From docs: Log Levels
|
|
1442
|
+
this.logger.trace('Very detailed info'); // Level 0
|
|
1443
|
+
this.logger.debug('Debug information'); // Level 1
|
|
1444
|
+
this.logger.info('General information'); // Level 2
|
|
1445
|
+
this.logger.warn('Warning message'); // Level 3
|
|
1446
|
+
this.logger.error('Error occurred'); // Level 4
|
|
1447
|
+
this.logger.fatal('Fatal error'); // Level 5
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
expect(EmailService).toBeDefined();
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', () => {
|
|
1457
|
+
describe('OnModuleInit Interface', () => {
|
|
1458
|
+
/**
|
|
1459
|
+
* @source docs/api/services.md#lifecycle-hooks
|
|
1460
|
+
*/
|
|
1461
|
+
it('should implement OnModuleInit interface', () => {
|
|
1462
|
+
// From docs: OnModuleInit example
|
|
1463
|
+
@Service()
|
|
1464
|
+
class DatabaseService extends BaseService implements OnModuleInit {
|
|
1465
|
+
private connection: unknown = null;
|
|
1466
|
+
|
|
1467
|
+
async onModuleInit(): Promise<void> {
|
|
1468
|
+
// Called after service instantiation and DI
|
|
1469
|
+
this.connection = { connected: true };
|
|
1470
|
+
this.logger.info('Database connected');
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
isConnected(): boolean {
|
|
1474
|
+
return this.connection !== null;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
expect(DatabaseService).toBeDefined();
|
|
1479
|
+
// Verify method exists
|
|
1480
|
+
const service = new DatabaseService();
|
|
1481
|
+
expect(typeof service.onModuleInit).toBe('function');
|
|
1482
|
+
});
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
describe('OnApplicationInit Interface', () => {
|
|
1486
|
+
/**
|
|
1487
|
+
* @source docs/api/services.md#lifecycle-hooks
|
|
1488
|
+
*/
|
|
1489
|
+
it('should implement OnApplicationInit interface', () => {
|
|
1490
|
+
// From docs: OnApplicationInit example
|
|
1491
|
+
@Service()
|
|
1492
|
+
class CacheService extends BaseService implements OnApplicationInit {
|
|
1493
|
+
async onApplicationInit(): Promise<void> {
|
|
1494
|
+
// Called after all modules initialized, before HTTP server starts
|
|
1495
|
+
this.logger.info('Warming up cache');
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
expect(CacheService).toBeDefined();
|
|
1500
|
+
const service = new CacheService();
|
|
1501
|
+
expect(typeof service.onApplicationInit).toBe('function');
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
describe('OnModuleDestroy Interface', () => {
|
|
1506
|
+
/**
|
|
1507
|
+
* @source docs/api/services.md#lifecycle-hooks
|
|
1508
|
+
*/
|
|
1509
|
+
it('should implement OnModuleDestroy interface', () => {
|
|
1510
|
+
// From docs: OnModuleDestroy example
|
|
1511
|
+
@Service()
|
|
1512
|
+
class ConnectionService extends BaseService implements OnModuleDestroy {
|
|
1513
|
+
async onModuleDestroy(): Promise<void> {
|
|
1514
|
+
// Called during shutdown, after HTTP server stops
|
|
1515
|
+
this.logger.info('Closing connections');
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
expect(ConnectionService).toBeDefined();
|
|
956
1520
|
const service = new ConnectionService();
|
|
957
1521
|
expect(typeof service.onModuleDestroy).toBe('function');
|
|
958
1522
|
});
|
|
@@ -3376,123 +3940,50 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
|
|
|
3376
3940
|
@WebSocketGateway({ path: '/chat' })
|
|
3377
3941
|
class ChatGateway extends BaseWebSocketGateway {
|
|
3378
3942
|
constructor(private chatService: ChatService) {
|
|
3379
|
-
super();
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
@OnMessage('chat:message')
|
|
3383
|
-
handleMessage(@MessageData() data: { text: string }) {
|
|
3384
|
-
return { event: 'received', data };
|
|
3385
|
-
}
|
|
3386
|
-
}
|
|
3387
|
-
|
|
3388
|
-
@Module({
|
|
3389
|
-
controllers: [ChatGateway],
|
|
3390
|
-
providers: [ChatService],
|
|
3391
|
-
})
|
|
3392
|
-
class ChatModule {}
|
|
3393
|
-
|
|
3394
|
-
// From docs: Typed Client (native) example
|
|
3395
|
-
const definition = createWsServiceDefinition(ChatModule);
|
|
3396
|
-
const client = createWsClient(definition, {
|
|
3397
|
-
url: 'ws://localhost:3000/chat',
|
|
3398
|
-
protocol: 'native',
|
|
3399
|
-
auth: {
|
|
3400
|
-
token: 'user-jwt-token',
|
|
3401
|
-
},
|
|
3402
|
-
reconnect: true,
|
|
3403
|
-
reconnectInterval: 2000,
|
|
3404
|
-
maxReconnectAttempts: 5,
|
|
3405
|
-
});
|
|
3406
|
-
|
|
3407
|
-
// Lifecycle events
|
|
3408
|
-
expect(typeof client.on).toBe('function');
|
|
3409
|
-
|
|
3410
|
-
// Connect/disconnect
|
|
3411
|
-
expect(typeof client.connect).toBe('function');
|
|
3412
|
-
expect(typeof client.disconnect).toBe('function');
|
|
3413
|
-
|
|
3414
|
-
// Gateway access
|
|
3415
|
-
expect(client.ChatGateway).toBeDefined();
|
|
3416
|
-
});
|
|
3417
|
-
});
|
|
3418
|
-
});
|
|
3419
|
-
|
|
3420
|
-
// ============================================================================
|
|
3421
|
-
// SSE (Server-Sent Events) Documentation Tests
|
|
3422
|
-
// ============================================================================
|
|
3423
|
-
|
|
3424
|
-
import { Sse, getSseMetadata } from './decorators/decorators';
|
|
3425
|
-
import { formatSseEvent, createSseStream } from './module/controller';
|
|
3426
|
-
|
|
3427
|
-
import {
|
|
3428
|
-
Controller,
|
|
3429
|
-
Get,
|
|
3430
|
-
Post,
|
|
3431
|
-
Put,
|
|
3432
|
-
Delete,
|
|
3433
|
-
Patch,
|
|
3434
|
-
Param,
|
|
3435
|
-
Query,
|
|
3436
|
-
Body,
|
|
3437
|
-
Header,
|
|
3438
|
-
Req,
|
|
3439
|
-
Cookie,
|
|
3440
|
-
Module,
|
|
3441
|
-
Service,
|
|
3442
|
-
BaseService,
|
|
3443
|
-
BaseController,
|
|
3444
|
-
UseMiddleware,
|
|
3445
|
-
getServiceTag,
|
|
3446
|
-
getControllerMetadata,
|
|
3447
|
-
HttpStatusCode,
|
|
3448
|
-
ParamType,
|
|
3449
|
-
NotFoundError,
|
|
3450
|
-
InternalServerError,
|
|
3451
|
-
OneBunBaseError,
|
|
3452
|
-
Env,
|
|
3453
|
-
validate,
|
|
3454
|
-
validateOrThrow,
|
|
3455
|
-
MultiServiceApplication,
|
|
3456
|
-
OneBunApplication,
|
|
3457
|
-
createServiceDefinition,
|
|
3458
|
-
createServiceClient,
|
|
3459
|
-
WebSocketGateway,
|
|
3460
|
-
BaseWebSocketGateway,
|
|
3461
|
-
OnConnect,
|
|
3462
|
-
OnDisconnect,
|
|
3463
|
-
OnJoinRoom,
|
|
3464
|
-
OnLeaveRoom,
|
|
3465
|
-
OnMessage,
|
|
3466
|
-
Client,
|
|
3467
|
-
Socket,
|
|
3468
|
-
MessageData,
|
|
3469
|
-
RoomName,
|
|
3470
|
-
PatternParams,
|
|
3471
|
-
WsServer,
|
|
3472
|
-
UseWsGuards,
|
|
3473
|
-
WsAuthGuard,
|
|
3474
|
-
WsPermissionGuard,
|
|
3475
|
-
WsAnyPermissionGuard,
|
|
3476
|
-
createGuard,
|
|
3477
|
-
createInMemoryWsStorage,
|
|
3478
|
-
SharedRedisProvider,
|
|
3479
|
-
createWsServiceDefinition,
|
|
3480
|
-
createWsClient,
|
|
3481
|
-
createNativeWsClient,
|
|
3482
|
-
matchPattern,
|
|
3483
|
-
makeMockLoggerLayer,
|
|
3484
|
-
hasOnModuleInit,
|
|
3485
|
-
hasOnApplicationInit,
|
|
3486
|
-
hasOnModuleDestroy,
|
|
3487
|
-
hasBeforeApplicationDestroy,
|
|
3488
|
-
hasOnApplicationDestroy,
|
|
3489
|
-
callOnModuleInit,
|
|
3490
|
-
callOnApplicationInit,
|
|
3491
|
-
callOnModuleDestroy,
|
|
3492
|
-
callBeforeApplicationDestroy,
|
|
3493
|
-
callOnApplicationDestroy,
|
|
3494
|
-
} from './';
|
|
3943
|
+
super();
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
@OnMessage('chat:message')
|
|
3947
|
+
handleMessage(@MessageData() data: { text: string }) {
|
|
3948
|
+
return { event: 'received', data };
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
@Module({
|
|
3953
|
+
controllers: [ChatGateway],
|
|
3954
|
+
providers: [ChatService],
|
|
3955
|
+
})
|
|
3956
|
+
class ChatModule {}
|
|
3957
|
+
|
|
3958
|
+
// From docs: Typed Client (native) example
|
|
3959
|
+
const definition = createWsServiceDefinition(ChatModule);
|
|
3960
|
+
const client = createWsClient(definition, {
|
|
3961
|
+
url: 'ws://localhost:3000/chat',
|
|
3962
|
+
protocol: 'native',
|
|
3963
|
+
auth: {
|
|
3964
|
+
token: 'user-jwt-token',
|
|
3965
|
+
},
|
|
3966
|
+
reconnect: true,
|
|
3967
|
+
reconnectInterval: 2000,
|
|
3968
|
+
maxReconnectAttempts: 5,
|
|
3969
|
+
});
|
|
3970
|
+
|
|
3971
|
+
// Lifecycle events
|
|
3972
|
+
expect(typeof client.on).toBe('function');
|
|
3973
|
+
|
|
3974
|
+
// Connect/disconnect
|
|
3975
|
+
expect(typeof client.connect).toBe('function');
|
|
3976
|
+
expect(typeof client.disconnect).toBe('function');
|
|
3977
|
+
|
|
3978
|
+
// Gateway access
|
|
3979
|
+
expect(client.ChatGateway).toBeDefined();
|
|
3980
|
+
});
|
|
3981
|
+
});
|
|
3982
|
+
});
|
|
3495
3983
|
|
|
3984
|
+
// ============================================================================
|
|
3985
|
+
// SSE (Server-Sent Events) Documentation Tests
|
|
3986
|
+
// ============================================================================
|
|
3496
3987
|
|
|
3497
3988
|
describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)', () => {
|
|
3498
3989
|
describe('SseEvent Type (docs/api/controllers.md)', () => {
|
|
@@ -3728,6 +4219,91 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
|
|
|
3728
4219
|
// Should have the actual event (string data is not wrapped in extra quotes)
|
|
3729
4220
|
expect(output).toContain('data: done');
|
|
3730
4221
|
});
|
|
4222
|
+
|
|
4223
|
+
it('should call iterator.return() on cancel, triggering generator finally block', async () => {
|
|
4224
|
+
let finallyCalled = false;
|
|
4225
|
+
|
|
4226
|
+
async function* testGenerator(): SseGenerator {
|
|
4227
|
+
try {
|
|
4228
|
+
yield { event: 'start', data: { count: 0 } };
|
|
4229
|
+
// Simulate a long-running generator
|
|
4230
|
+
yield { event: 'tick', data: { count: 1 } };
|
|
4231
|
+
yield { event: 'tick', data: { count: 2 } };
|
|
4232
|
+
} finally {
|
|
4233
|
+
finallyCalled = true;
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
const stream = createSseStream(testGenerator());
|
|
4238
|
+
const reader = stream.getReader();
|
|
4239
|
+
|
|
4240
|
+
// Read first chunk
|
|
4241
|
+
const first = await reader.read();
|
|
4242
|
+
expect(first.done).toBe(false);
|
|
4243
|
+
|
|
4244
|
+
// Cancel the stream (simulates client disconnect)
|
|
4245
|
+
await reader.cancel();
|
|
4246
|
+
|
|
4247
|
+
// The generator's finally block should have been triggered
|
|
4248
|
+
// Give a tick for the async return to settle
|
|
4249
|
+
await Bun.sleep(10);
|
|
4250
|
+
expect(finallyCalled).toBe(true);
|
|
4251
|
+
});
|
|
4252
|
+
|
|
4253
|
+
it('should fire onAbort callback on cancel', async () => {
|
|
4254
|
+
let abortCalled = false;
|
|
4255
|
+
|
|
4256
|
+
async function* testGenerator(): SseGenerator {
|
|
4257
|
+
yield { event: 'start', data: { count: 0 } };
|
|
4258
|
+
yield { event: 'tick', data: { count: 1 } };
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
const stream = createSseStream(testGenerator(), {
|
|
4262
|
+
onAbort() {
|
|
4263
|
+
abortCalled = true;
|
|
4264
|
+
},
|
|
4265
|
+
});
|
|
4266
|
+
const reader = stream.getReader();
|
|
4267
|
+
|
|
4268
|
+
// Read first chunk
|
|
4269
|
+
await reader.read();
|
|
4270
|
+
|
|
4271
|
+
// Cancel the stream
|
|
4272
|
+
await reader.cancel();
|
|
4273
|
+
|
|
4274
|
+
expect(abortCalled).toBe(true);
|
|
4275
|
+
});
|
|
4276
|
+
|
|
4277
|
+
it('should abort upstream fetch via try/finally when client disconnects (SSE proxy pattern)', async () => {
|
|
4278
|
+
let upstreamAborted = false;
|
|
4279
|
+
const ac = new AbortController();
|
|
4280
|
+
|
|
4281
|
+
async function* proxyGenerator(): SseGenerator {
|
|
4282
|
+
try {
|
|
4283
|
+
ac.signal.addEventListener('abort', () => {
|
|
4284
|
+
upstreamAborted = true;
|
|
4285
|
+
});
|
|
4286
|
+
yield { event: 'proxied', data: { from: 'upstream' } };
|
|
4287
|
+
// Simulate waiting for more upstream data
|
|
4288
|
+
yield { event: 'proxied', data: { from: 'upstream-2' } };
|
|
4289
|
+
} finally {
|
|
4290
|
+
// This is the idiomatic pattern: abort the upstream connection in finally
|
|
4291
|
+
ac.abort();
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
const stream = createSseStream(proxyGenerator());
|
|
4296
|
+
const reader = stream.getReader();
|
|
4297
|
+
|
|
4298
|
+
// Read first event
|
|
4299
|
+
await reader.read();
|
|
4300
|
+
|
|
4301
|
+
// Client disconnects
|
|
4302
|
+
await reader.cancel();
|
|
4303
|
+
|
|
4304
|
+
await Bun.sleep(10);
|
|
4305
|
+
expect(upstreamAborted).toBe(true);
|
|
4306
|
+
});
|
|
3731
4307
|
});
|
|
3732
4308
|
|
|
3733
4309
|
describe('Controller.sse() Method', () => {
|
|
@@ -3759,6 +4335,79 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
|
|
|
3759
4335
|
|
|
3760
4336
|
expect(EventsController).toBeDefined();
|
|
3761
4337
|
});
|
|
4338
|
+
|
|
4339
|
+
it('should accept factory function with AbortSignal for SSE proxy pattern', async () => {
|
|
4340
|
+
let signalAborted = false;
|
|
4341
|
+
|
|
4342
|
+
@Controller('/events')
|
|
4343
|
+
class ProxyController extends BaseController {
|
|
4344
|
+
@Get('/proxy')
|
|
4345
|
+
proxy(): Response {
|
|
4346
|
+
return this.sse((signal) => this.proxyUpstream(signal));
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
private async *proxyUpstream(signal: AbortSignal): SseGenerator {
|
|
4350
|
+
signal.addEventListener('abort', () => {
|
|
4351
|
+
signalAborted = true;
|
|
4352
|
+
});
|
|
4353
|
+
yield { event: 'proxied', data: { from: 'upstream' } };
|
|
4354
|
+
yield { event: 'proxied', data: { from: 'upstream-2' } };
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
const controller = new ProxyController();
|
|
4359
|
+
// Access protected method via type assertion
|
|
4360
|
+
const sseMethod = (controller as unknown as {
|
|
4361
|
+
sse: (source: (signal: AbortSignal) => AsyncIterable<unknown>, options?: unknown) => Response;
|
|
4362
|
+
}).sse.bind(controller);
|
|
4363
|
+
|
|
4364
|
+
const response = sseMethod((signal) => (async function* () {
|
|
4365
|
+
signal.addEventListener('abort', () => {
|
|
4366
|
+
signalAborted = true;
|
|
4367
|
+
});
|
|
4368
|
+
yield { event: 'proxied', data: { from: 'upstream' } };
|
|
4369
|
+
})());
|
|
4370
|
+
|
|
4371
|
+
expect(response).toBeInstanceOf(Response);
|
|
4372
|
+
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
|
|
4373
|
+
|
|
4374
|
+
const reader = response.body!.getReader();
|
|
4375
|
+
|
|
4376
|
+
// Read first event
|
|
4377
|
+
await reader.read();
|
|
4378
|
+
|
|
4379
|
+
// Client disconnects
|
|
4380
|
+
await reader.cancel();
|
|
4381
|
+
|
|
4382
|
+
await Bun.sleep(10);
|
|
4383
|
+
expect(signalAborted).toBe(true);
|
|
4384
|
+
});
|
|
4385
|
+
|
|
4386
|
+
it('should support onAbort callback with sse() helper', async () => {
|
|
4387
|
+
let abortCalled = false;
|
|
4388
|
+
|
|
4389
|
+
const controller = new BaseController();
|
|
4390
|
+
const sseMethod = (controller as unknown as {
|
|
4391
|
+
sse: (source: AsyncIterable<unknown>, options?: { onAbort?: () => void }) => Response;
|
|
4392
|
+
}).sse.bind(controller);
|
|
4393
|
+
|
|
4394
|
+
const response = sseMethod(
|
|
4395
|
+
(async function* () {
|
|
4396
|
+
yield { event: 'tick', data: { count: 0 } };
|
|
4397
|
+
})(),
|
|
4398
|
+
{
|
|
4399
|
+
onAbort() {
|
|
4400
|
+
abortCalled = true;
|
|
4401
|
+
},
|
|
4402
|
+
},
|
|
4403
|
+
);
|
|
4404
|
+
|
|
4405
|
+
const reader = response.body!.getReader();
|
|
4406
|
+
await reader.read();
|
|
4407
|
+
await reader.cancel();
|
|
4408
|
+
|
|
4409
|
+
expect(abortCalled).toBe(true);
|
|
4410
|
+
});
|
|
3762
4411
|
});
|
|
3763
4412
|
|
|
3764
4413
|
describe('Complete SSE Controller Example (docs/api/controllers.md)', () => {
|
|
@@ -3838,6 +4487,148 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
|
|
|
3838
4487
|
});
|
|
3839
4488
|
});
|
|
3840
4489
|
|
|
4490
|
+
// ============================================================================
|
|
4491
|
+
// Server & SSE Default Constants
|
|
4492
|
+
// ============================================================================
|
|
4493
|
+
|
|
4494
|
+
describe('Server & SSE Default Constants (docs/api/core.md, docs/api/controllers.md)', () => {
|
|
4495
|
+
it('should export DEFAULT_IDLE_TIMEOUT as 120 seconds', () => {
|
|
4496
|
+
expect(DEFAULT_IDLE_TIMEOUT).toBe(120);
|
|
4497
|
+
});
|
|
4498
|
+
|
|
4499
|
+
it('should export DEFAULT_SSE_HEARTBEAT_MS as 30000 milliseconds', () => {
|
|
4500
|
+
expect(DEFAULT_SSE_HEARTBEAT_MS).toBe(30_000);
|
|
4501
|
+
});
|
|
4502
|
+
|
|
4503
|
+
it('should export DEFAULT_SSE_TIMEOUT as 600 seconds (10 minutes)', () => {
|
|
4504
|
+
expect(DEFAULT_SSE_TIMEOUT).toBe(600);
|
|
4505
|
+
});
|
|
4506
|
+
});
|
|
4507
|
+
|
|
4508
|
+
// ============================================================================
|
|
4509
|
+
// Per-request timeout via route decorators (docs/api/decorators.md)
|
|
4510
|
+
// ============================================================================
|
|
4511
|
+
|
|
4512
|
+
describe('Per-request timeout via route decorators (docs/api/decorators.md)', () => {
|
|
4513
|
+
it('should store timeout in route metadata when specified via @Get options', () => {
|
|
4514
|
+
@Controller('/tasks')
|
|
4515
|
+
class TaskController extends BaseController {
|
|
4516
|
+
@Get('/process', { timeout: 300 })
|
|
4517
|
+
async process() {
|
|
4518
|
+
return new Response('OK');
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4522
|
+
const metadata = getControllerMetadata(TaskController);
|
|
4523
|
+
expect(metadata).toBeDefined();
|
|
4524
|
+
const route = metadata!.routes.find((r) => r.handler === 'process');
|
|
4525
|
+
expect(route).toBeDefined();
|
|
4526
|
+
expect(route!.timeout).toBe(300);
|
|
4527
|
+
});
|
|
4528
|
+
|
|
4529
|
+
it('should store timeout: 0 (disable) in route metadata', () => {
|
|
4530
|
+
@Controller('/export')
|
|
4531
|
+
class ExportController extends BaseController {
|
|
4532
|
+
@Get('/all', { timeout: 0 })
|
|
4533
|
+
async exportAll() {
|
|
4534
|
+
return new Response('OK');
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
const metadata = getControllerMetadata(ExportController);
|
|
4539
|
+
expect(metadata).toBeDefined();
|
|
4540
|
+
const route = metadata!.routes.find((r) => r.handler === 'exportAll');
|
|
4541
|
+
expect(route).toBeDefined();
|
|
4542
|
+
expect(route!.timeout).toBe(0);
|
|
4543
|
+
});
|
|
4544
|
+
|
|
4545
|
+
it('should not include timeout in metadata when not specified', () => {
|
|
4546
|
+
@Controller('/simple')
|
|
4547
|
+
class SimpleController extends BaseController {
|
|
4548
|
+
@Get('/hello')
|
|
4549
|
+
async hello() {
|
|
4550
|
+
return new Response('OK');
|
|
4551
|
+
}
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
const metadata = getControllerMetadata(SimpleController);
|
|
4555
|
+
expect(metadata).toBeDefined();
|
|
4556
|
+
const route = metadata!.routes.find((r) => r.handler === 'hello');
|
|
4557
|
+
expect(route).toBeDefined();
|
|
4558
|
+
expect(route!.timeout).toBeUndefined();
|
|
4559
|
+
});
|
|
4560
|
+
|
|
4561
|
+
it('should support timeout on @Post, @Put, @Delete, @Patch', () => {
|
|
4562
|
+
@Controller('/api')
|
|
4563
|
+
class CrudController extends BaseController {
|
|
4564
|
+
@Post('/create', { timeout: 60 })
|
|
4565
|
+
async create() {
|
|
4566
|
+
return new Response('OK');
|
|
4567
|
+
}
|
|
4568
|
+
|
|
4569
|
+
@Put('/update', { timeout: 120 })
|
|
4570
|
+
async update() {
|
|
4571
|
+
return new Response('OK');
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
@Delete('/remove', { timeout: 30 })
|
|
4575
|
+
async remove() {
|
|
4576
|
+
return new Response('OK');
|
|
4577
|
+
}
|
|
4578
|
+
|
|
4579
|
+
@Patch('/patch', { timeout: 45 })
|
|
4580
|
+
async patch() {
|
|
4581
|
+
return new Response('OK');
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
const metadata = getControllerMetadata(CrudController);
|
|
4586
|
+
expect(metadata).toBeDefined();
|
|
4587
|
+
|
|
4588
|
+
expect(metadata!.routes.find((r) => r.handler === 'create')!.timeout).toBe(60);
|
|
4589
|
+
expect(metadata!.routes.find((r) => r.handler === 'update')!.timeout).toBe(120);
|
|
4590
|
+
expect(metadata!.routes.find((r) => r.handler === 'remove')!.timeout).toBe(30);
|
|
4591
|
+
expect(metadata!.routes.find((r) => r.handler === 'patch')!.timeout).toBe(45);
|
|
4592
|
+
});
|
|
4593
|
+
|
|
4594
|
+
it('should store timeout in SSE decorator options', () => {
|
|
4595
|
+
@Controller('/events')
|
|
4596
|
+
class SseTimeoutController extends BaseController {
|
|
4597
|
+
@Get('/stream')
|
|
4598
|
+
@Sse({ timeout: 3600, heartbeat: 10000 })
|
|
4599
|
+
async *stream(): SseGenerator {
|
|
4600
|
+
yield { event: 'tick', data: { count: 0 } };
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
|
|
4604
|
+
const sseOptions = getSseMetadata(
|
|
4605
|
+
SseTimeoutController.prototype,
|
|
4606
|
+
'stream',
|
|
4607
|
+
);
|
|
4608
|
+
expect(sseOptions).toBeDefined();
|
|
4609
|
+
expect(sseOptions!.timeout).toBe(3600);
|
|
4610
|
+
expect(sseOptions!.heartbeat).toBe(10000);
|
|
4611
|
+
});
|
|
4612
|
+
|
|
4613
|
+
it('should support timeout: 0 in @Sse to disable timeout', () => {
|
|
4614
|
+
@Controller('/events')
|
|
4615
|
+
class InfiniteSseController extends BaseController {
|
|
4616
|
+
@Get('/infinite')
|
|
4617
|
+
@Sse({ timeout: 0 })
|
|
4618
|
+
async *infinite(): SseGenerator {
|
|
4619
|
+
yield { event: 'start', data: {} };
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4623
|
+
const sseOptions = getSseMetadata(
|
|
4624
|
+
InfiniteSseController.prototype,
|
|
4625
|
+
'infinite',
|
|
4626
|
+
);
|
|
4627
|
+
expect(sseOptions).toBeDefined();
|
|
4628
|
+
expect(sseOptions!.timeout).toBe(0);
|
|
4629
|
+
});
|
|
4630
|
+
});
|
|
4631
|
+
|
|
3841
4632
|
// ============================================================================
|
|
3842
4633
|
// Cookie, Headers, @Req with OneBunRequest
|
|
3843
4634
|
// ============================================================================
|
|
@@ -4128,3 +4919,297 @@ describe('Working with Cookies (docs/api/controllers.md)', () => {
|
|
|
4128
4919
|
expect(AuthController).toBeDefined();
|
|
4129
4920
|
});
|
|
4130
4921
|
});
|
|
4922
|
+
|
|
4923
|
+
// ============================================================================
|
|
4924
|
+
// File Upload Documentation Tests
|
|
4925
|
+
// ============================================================================
|
|
4926
|
+
|
|
4927
|
+
describe('File Upload API Documentation (docs/api/decorators.md)', () => {
|
|
4928
|
+
/**
|
|
4929
|
+
* @source docs/api/decorators.md#uploadedfile
|
|
4930
|
+
*/
|
|
4931
|
+
describe('Single File Upload (docs/api/decorators.md#uploadedfile)', () => {
|
|
4932
|
+
it('should define controller with @UploadedFile decorator', () => {
|
|
4933
|
+
@Controller('/api/files')
|
|
4934
|
+
class FileController extends BaseController {
|
|
4935
|
+
@Post('/avatar')
|
|
4936
|
+
async uploadAvatar(
|
|
4937
|
+
@UploadedFile('avatar', {
|
|
4938
|
+
maxSize: 5 * 1024 * 1024,
|
|
4939
|
+
mimeTypes: [MimeType.ANY_IMAGE],
|
|
4940
|
+
}) file: OneBunFile,
|
|
4941
|
+
): Promise<Response> {
|
|
4942
|
+
await file.writeTo(`./uploads/${file.name}`);
|
|
4943
|
+
|
|
4944
|
+
return this.success({ filename: file.name, size: file.size });
|
|
4945
|
+
}
|
|
4946
|
+
}
|
|
4947
|
+
|
|
4948
|
+
expect(FileController).toBeDefined();
|
|
4949
|
+
const metadata = getControllerMetadata(FileController);
|
|
4950
|
+
expect(metadata).toBeDefined();
|
|
4951
|
+
expect(metadata!.routes).toHaveLength(1);
|
|
4952
|
+
|
|
4953
|
+
const route = metadata!.routes[0];
|
|
4954
|
+
expect(route.params).toBeDefined();
|
|
4955
|
+
expect(route.params!.length).toBe(1);
|
|
4956
|
+
expect(route.params![0].type).toBe(ParamType.FILE);
|
|
4957
|
+
expect(route.params![0].name).toBe('avatar');
|
|
4958
|
+
expect(route.params![0].isRequired).toBe(true);
|
|
4959
|
+
expect(route.params![0].fileOptions).toBeDefined();
|
|
4960
|
+
expect(route.params![0].fileOptions!.maxSize).toBe(5 * 1024 * 1024);
|
|
4961
|
+
expect(route.params![0].fileOptions!.mimeTypes).toEqual([MimeType.ANY_IMAGE]);
|
|
4962
|
+
});
|
|
4963
|
+
});
|
|
4964
|
+
|
|
4965
|
+
/**
|
|
4966
|
+
* @source docs/api/decorators.md#uploadedfiles
|
|
4967
|
+
*/
|
|
4968
|
+
describe('Multiple File Upload (docs/api/decorators.md#uploadedfiles)', () => {
|
|
4969
|
+
it('should define controller with @UploadedFiles decorator', () => {
|
|
4970
|
+
@Controller('/api/files')
|
|
4971
|
+
class FileController extends BaseController {
|
|
4972
|
+
@Post('/documents')
|
|
4973
|
+
async uploadDocs(
|
|
4974
|
+
@UploadedFiles('docs', { maxCount: 10 }) files: OneBunFile[],
|
|
4975
|
+
): Promise<Response> {
|
|
4976
|
+
for (const file of files) {
|
|
4977
|
+
await file.writeTo(`./uploads/${file.name}`);
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
return this.success({ count: files.length });
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4984
|
+
expect(FileController).toBeDefined();
|
|
4985
|
+
const metadata = getControllerMetadata(FileController);
|
|
4986
|
+
expect(metadata).toBeDefined();
|
|
4987
|
+
|
|
4988
|
+
const route = metadata!.routes[0];
|
|
4989
|
+
expect(route.params).toBeDefined();
|
|
4990
|
+
expect(route.params![0].type).toBe(ParamType.FILES);
|
|
4991
|
+
expect(route.params![0].name).toBe('docs');
|
|
4992
|
+
expect(route.params![0].fileOptions!.maxCount).toBe(10);
|
|
4993
|
+
});
|
|
4994
|
+
|
|
4995
|
+
it('should support @UploadedFiles without field name (all files)', () => {
|
|
4996
|
+
@Controller('/api/files')
|
|
4997
|
+
class FileController extends BaseController {
|
|
4998
|
+
@Post('/batch')
|
|
4999
|
+
async uploadBatch(
|
|
5000
|
+
@UploadedFiles(undefined, { maxCount: 20 }) files: OneBunFile[],
|
|
5001
|
+
): Promise<Response> {
|
|
5002
|
+
return this.success({ count: files.length });
|
|
5003
|
+
}
|
|
5004
|
+
}
|
|
5005
|
+
|
|
5006
|
+
expect(FileController).toBeDefined();
|
|
5007
|
+
const metadata = getControllerMetadata(FileController);
|
|
5008
|
+
const route = metadata!.routes[0];
|
|
5009
|
+
expect(route.params![0].type).toBe(ParamType.FILES);
|
|
5010
|
+
expect(route.params![0].name).toBe('');
|
|
5011
|
+
});
|
|
5012
|
+
});
|
|
5013
|
+
|
|
5014
|
+
/**
|
|
5015
|
+
* @source docs/api/decorators.md#formfield
|
|
5016
|
+
*/
|
|
5017
|
+
describe('Form Field (docs/api/decorators.md#formfield)', () => {
|
|
5018
|
+
it('should define controller with @FormField decorator', () => {
|
|
5019
|
+
@Controller('/api/files')
|
|
5020
|
+
class FileController extends BaseController {
|
|
5021
|
+
@Post('/profile')
|
|
5022
|
+
async createProfile(
|
|
5023
|
+
@UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile,
|
|
5024
|
+
@FormField('name', { required: true }) name: string,
|
|
5025
|
+
@FormField('email') email: string,
|
|
5026
|
+
): Promise<Response> {
|
|
5027
|
+
await avatar.writeTo(`./uploads/${avatar.name}`);
|
|
5028
|
+
|
|
5029
|
+
return this.success({ name, email, avatar: avatar.name });
|
|
5030
|
+
}
|
|
5031
|
+
}
|
|
5032
|
+
|
|
5033
|
+
expect(FileController).toBeDefined();
|
|
5034
|
+
const metadata = getControllerMetadata(FileController);
|
|
5035
|
+
const route = metadata!.routes[0];
|
|
5036
|
+
expect(route.params).toBeDefined();
|
|
5037
|
+
expect(route.params!.length).toBe(3);
|
|
5038
|
+
|
|
5039
|
+
// @UploadedFile
|
|
5040
|
+
const fileParam = route.params!.find((p) => p.type === ParamType.FILE);
|
|
5041
|
+
expect(fileParam).toBeDefined();
|
|
5042
|
+
expect(fileParam!.name).toBe('avatar');
|
|
5043
|
+
|
|
5044
|
+
// @FormField (required)
|
|
5045
|
+
const nameParam = route.params!.find((p) => p.name === 'name');
|
|
5046
|
+
expect(nameParam).toBeDefined();
|
|
5047
|
+
expect(nameParam!.type).toBe(ParamType.FORM_FIELD);
|
|
5048
|
+
expect(nameParam!.isRequired).toBe(true);
|
|
5049
|
+
|
|
5050
|
+
// @FormField (optional)
|
|
5051
|
+
const emailParam = route.params!.find((p) => p.name === 'email');
|
|
5052
|
+
expect(emailParam).toBeDefined();
|
|
5053
|
+
expect(emailParam!.type).toBe(ParamType.FORM_FIELD);
|
|
5054
|
+
expect(emailParam!.isRequired).toBe(false);
|
|
5055
|
+
});
|
|
5056
|
+
});
|
|
5057
|
+
|
|
5058
|
+
/**
|
|
5059
|
+
* @source docs/api/decorators.md#onebunfile
|
|
5060
|
+
*/
|
|
5061
|
+
describe('OneBunFile Class (docs/api/decorators.md#onebunfile)', () => {
|
|
5062
|
+
it('should create OneBunFile from File and support all methods', async () => {
|
|
5063
|
+
const content = 'test file content';
|
|
5064
|
+
const file = new File([content], 'test.txt', { type: 'text/plain' });
|
|
5065
|
+
const oneBunFile = new OneBunFile(file);
|
|
5066
|
+
|
|
5067
|
+
expect(oneBunFile.name).toBe('test.txt');
|
|
5068
|
+
expect(oneBunFile.size).toBe(content.length);
|
|
5069
|
+
expect(oneBunFile.type).toStartWith('text/plain');
|
|
5070
|
+
|
|
5071
|
+
const base64 = await oneBunFile.toBase64();
|
|
5072
|
+
expect(base64).toBe(btoa(content));
|
|
5073
|
+
|
|
5074
|
+
const buffer = await oneBunFile.toBuffer();
|
|
5075
|
+
expect(buffer.toString()).toBe(content);
|
|
5076
|
+
|
|
5077
|
+
const blob = oneBunFile.toBlob();
|
|
5078
|
+
expect(blob.size).toBe(content.length);
|
|
5079
|
+
});
|
|
5080
|
+
|
|
5081
|
+
it('should create OneBunFile from base64', async () => {
|
|
5082
|
+
const content = 'base64 content';
|
|
5083
|
+
const base64 = btoa(content);
|
|
5084
|
+
const file = OneBunFile.fromBase64(base64, 'decoded.txt', 'text/plain');
|
|
5085
|
+
|
|
5086
|
+
expect(file.name).toBe('decoded.txt');
|
|
5087
|
+
expect(file.type).toStartWith('text/plain');
|
|
5088
|
+
|
|
5089
|
+
const roundTripped = await file.toBase64();
|
|
5090
|
+
expect(roundTripped).toBe(base64);
|
|
5091
|
+
});
|
|
5092
|
+
});
|
|
5093
|
+
|
|
5094
|
+
/**
|
|
5095
|
+
* @source docs/api/decorators.md#mimetype-enum
|
|
5096
|
+
*/
|
|
5097
|
+
describe('MimeType Enum (docs/api/decorators.md#mimetype-enum)', () => {
|
|
5098
|
+
it('should provide common MIME type constants', () => {
|
|
5099
|
+
// Wildcards
|
|
5100
|
+
expect(String(MimeType.ANY)).toBe('*/*');
|
|
5101
|
+
expect(String(MimeType.ANY_IMAGE)).toBe('image/*');
|
|
5102
|
+
expect(String(MimeType.ANY_VIDEO)).toBe('video/*');
|
|
5103
|
+
expect(String(MimeType.ANY_AUDIO)).toBe('audio/*');
|
|
5104
|
+
|
|
5105
|
+
// Specific types
|
|
5106
|
+
expect(String(MimeType.PNG)).toBe('image/png');
|
|
5107
|
+
expect(String(MimeType.PDF)).toBe('application/pdf');
|
|
5108
|
+
expect(String(MimeType.MP4)).toBe('video/mp4');
|
|
5109
|
+
|
|
5110
|
+
// Wildcard matching
|
|
5111
|
+
expect(matchMimeType('image/png', MimeType.ANY_IMAGE)).toBe(true);
|
|
5112
|
+
expect(matchMimeType('video/mp4', MimeType.ANY_IMAGE)).toBe(false);
|
|
5113
|
+
});
|
|
5114
|
+
});
|
|
5115
|
+
|
|
5116
|
+
/**
|
|
5117
|
+
* @source docs/api/decorators.md#json-base64-upload-format
|
|
5118
|
+
*/
|
|
5119
|
+
describe('JSON Base64 Upload (docs/api/decorators.md#json-base64-upload-format)', () => {
|
|
5120
|
+
it('should parse full JSON base64 format', () => {
|
|
5121
|
+
const base64 = btoa('png image data');
|
|
5122
|
+
const file = OneBunFile.fromBase64(base64, 'photo.png', 'image/png');
|
|
5123
|
+
|
|
5124
|
+
expect(file.name).toBe('photo.png');
|
|
5125
|
+
expect(file.type).toBe('image/png');
|
|
5126
|
+
});
|
|
5127
|
+
|
|
5128
|
+
it('should parse data URI format', () => {
|
|
5129
|
+
const base64 = btoa('svg data');
|
|
5130
|
+
const dataUri = `data:image/svg+xml;base64,${base64}`;
|
|
5131
|
+
const file = OneBunFile.fromBase64(dataUri, 'icon.svg');
|
|
5132
|
+
|
|
5133
|
+
expect(file.type).toBe('image/svg+xml');
|
|
5134
|
+
expect(file.name).toBe('icon.svg');
|
|
5135
|
+
});
|
|
5136
|
+
});
|
|
5137
|
+
});
|
|
5138
|
+
|
|
5139
|
+
describe('File Upload API Documentation (docs/api/controllers.md)', () => {
|
|
5140
|
+
/**
|
|
5141
|
+
* @source docs/api/controllers.md#single-file-upload
|
|
5142
|
+
*/
|
|
5143
|
+
it('should define single file upload controller', () => {
|
|
5144
|
+
@Controller('/api/files')
|
|
5145
|
+
class FileController extends BaseController {
|
|
5146
|
+
@Post('/avatar')
|
|
5147
|
+
async uploadAvatar(
|
|
5148
|
+
@UploadedFile('avatar', {
|
|
5149
|
+
maxSize: 5 * 1024 * 1024,
|
|
5150
|
+
mimeTypes: [MimeType.ANY_IMAGE],
|
|
5151
|
+
}) file: OneBunFile,
|
|
5152
|
+
): Promise<Response> {
|
|
5153
|
+
await file.writeTo(`./uploads/${file.name}`);
|
|
5154
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
5155
|
+
const _base64 = await file.toBase64();
|
|
5156
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
5157
|
+
const _buffer = await file.toBuffer();
|
|
5158
|
+
|
|
5159
|
+
return this.success({
|
|
5160
|
+
filename: file.name,
|
|
5161
|
+
size: file.size,
|
|
5162
|
+
type: file.type,
|
|
5163
|
+
});
|
|
5164
|
+
}
|
|
5165
|
+
}
|
|
5166
|
+
|
|
5167
|
+
expect(FileController).toBeDefined();
|
|
5168
|
+
});
|
|
5169
|
+
|
|
5170
|
+
/**
|
|
5171
|
+
* @source docs/api/controllers.md#multiple-file-upload
|
|
5172
|
+
*/
|
|
5173
|
+
it('should define multiple file upload controller', () => {
|
|
5174
|
+
@Controller('/api/files')
|
|
5175
|
+
class FileController extends BaseController {
|
|
5176
|
+
@Post('/documents')
|
|
5177
|
+
async uploadDocuments(
|
|
5178
|
+
@UploadedFiles('docs', {
|
|
5179
|
+
maxCount: 10,
|
|
5180
|
+
maxSize: 10 * 1024 * 1024,
|
|
5181
|
+
mimeTypes: [MimeType.PDF, MimeType.DOCX],
|
|
5182
|
+
}) files: OneBunFile[],
|
|
5183
|
+
): Promise<Response> {
|
|
5184
|
+
for (const file of files) {
|
|
5185
|
+
await file.writeTo(`./uploads/${file.name}`);
|
|
5186
|
+
}
|
|
5187
|
+
|
|
5188
|
+
return this.success({ uploaded: files.length });
|
|
5189
|
+
}
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
expect(FileController).toBeDefined();
|
|
5193
|
+
});
|
|
5194
|
+
|
|
5195
|
+
/**
|
|
5196
|
+
* @source docs/api/controllers.md#file-with-form-fields
|
|
5197
|
+
*/
|
|
5198
|
+
it('should define file with form fields controller', () => {
|
|
5199
|
+
@Controller('/api/files')
|
|
5200
|
+
class FileController extends BaseController {
|
|
5201
|
+
@Post('/profile')
|
|
5202
|
+
async createProfile(
|
|
5203
|
+
@UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile,
|
|
5204
|
+
@FormField('name', { required: true }) name: string,
|
|
5205
|
+
@FormField('email') email: string,
|
|
5206
|
+
): Promise<Response> {
|
|
5207
|
+
await avatar.writeTo(`./uploads/${avatar.name}`);
|
|
5208
|
+
|
|
5209
|
+
return this.success({ name, email, avatar: avatar.name });
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
5212
|
+
|
|
5213
|
+
expect(FileController).toBeDefined();
|
|
5214
|
+
});
|
|
5215
|
+
});
|